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

2683 lines
105 KiB
Rust
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.
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
mod asset_kind;
pub use asset_kind::{
GameCreationAppAssetKind, NonCanonicalAssetKindReporter, set_non_canonical_asset_kind_reporter,
};
pub const GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION: &str = "game-creation-app.manifest.v1";
pub const GAME_CREATION_AGENT_RUN_SCHEMA_VERSION: &str = "game-creator-agent-run.v1";
pub const GAME_CREATION_AGENT_RUN_MAX_PASSES: u8 = 3;
pub const GAME_CREATION_AGENT_TOOL_CALL_MAX: u16 = 128;
pub const GAME_CREATION_APP_UI_DESIGN_DOC_ASSET_KIND: &str = "ui-design-doc";
pub const GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE: &str = "application/json";
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GameCreationAppPermission {
Auto,
Confirm,
Deny,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAppCommandDescriptor {
pub id: &'static str,
pub permission: GameCreationAppPermission,
}
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 67] = [
command("help.show", GameCreationAppPermission::Auto),
command("project.create", GameCreationAppPermission::Confirm),
command("project.rename", GameCreationAppPermission::Confirm),
command("project.status", GameCreationAppPermission::Auto),
command("project.index", GameCreationAppPermission::Auto),
command("project.checkpoint", GameCreationAppPermission::Confirm),
command("project.diff", GameCreationAppPermission::Auto),
command("project.git_inspect", GameCreationAppPermission::Auto),
command("project.git_commit", GameCreationAppPermission::Confirm),
command("project.patchset", GameCreationAppPermission::Confirm),
command("project.restore", GameCreationAppPermission::Confirm),
command("project.verify", GameCreationAppPermission::Confirm),
command("project.bootstrap", GameCreationAppPermission::Confirm),
command("project.export_package", GameCreationAppPermission::Confirm),
command("project.export_list", GameCreationAppPermission::Auto),
command("project.policy_read", GameCreationAppPermission::Auto),
command("project.policy_write", GameCreationAppPermission::Confirm),
command("task.list", GameCreationAppPermission::Auto),
command("task.create", GameCreationAppPermission::Confirm),
command("task.update", GameCreationAppPermission::Confirm),
command("agent.trace_read", GameCreationAppPermission::Auto),
command("agent.run_status", GameCreationAppPermission::Auto),
command("agent.compact", GameCreationAppPermission::Auto),
command("agent.kill", GameCreationAppPermission::Confirm),
command("agent.retry", GameCreationAppPermission::Confirm),
command("agent.resume", GameCreationAppPermission::Confirm),
command("agent.delegate", GameCreationAppPermission::Confirm),
command("agent.spawn_isolated", GameCreationAppPermission::Confirm),
command("agent.schedule_ready", GameCreationAppPermission::Confirm),
command("agent.capabilities", GameCreationAppPermission::Auto),
command("agent.audit", GameCreationAppPermission::Auto),
command("llm.config_check", GameCreationAppPermission::Auto),
command("mcp.call", GameCreationAppPermission::Auto),
command("game.generate_draft", GameCreationAppPermission::Confirm),
command("game.run_local", GameCreationAppPermission::Confirm),
command("file.list", GameCreationAppPermission::Auto),
command("file.read", GameCreationAppPermission::Auto),
command("file.write", GameCreationAppPermission::Confirm),
command("file.delete", GameCreationAppPermission::Confirm),
command("asset.list", GameCreationAppPermission::Auto),
command("asset.upload", GameCreationAppPermission::Confirm),
command("asset.register", GameCreationAppPermission::Confirm),
command("image.inspect", GameCreationAppPermission::Auto),
command("preview.start", GameCreationAppPermission::Confirm),
command("preview.validate", GameCreationAppPermission::Auto),
command("preview.open", GameCreationAppPermission::Confirm),
command("preview.stop", GameCreationAppPermission::Auto),
command("preview.status", GameCreationAppPermission::Auto),
command("command.run_limited", GameCreationAppPermission::Confirm),
command("command.exec", GameCreationAppPermission::Confirm),
command("command.output_read", GameCreationAppPermission::Auto),
command("command.start", GameCreationAppPermission::Confirm),
command("command.poll", GameCreationAppPermission::Auto),
command("command.stdin", GameCreationAppPermission::Confirm),
command("command.terminate", GameCreationAppPermission::Confirm),
command("cocos.editor.execute", GameCreationAppPermission::Confirm),
command("godot.editor.execute", GameCreationAppPermission::Confirm),
command("canvas.project_open", GameCreationAppPermission::Confirm),
command("canvas.project_sync", GameCreationAppPermission::Confirm),
command("canvas.asset_import", GameCreationAppPermission::Confirm),
command("canvas.asset_generate", GameCreationAppPermission::Confirm),
command("canvas.export_import", GameCreationAppPermission::Confirm),
command("memory.read", GameCreationAppPermission::Auto),
command("memory.write", GameCreationAppPermission::Confirm),
command("memory.delete", GameCreationAppPermission::Confirm),
command("conversation.read", GameCreationAppPermission::Auto),
command("conversation.write", GameCreationAppPermission::Auto),
];
const fn command(
id: &'static str,
permission: GameCreationAppPermission,
) -> GameCreationAppCommandDescriptor {
GameCreationAppCommandDescriptor { id, permission }
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAgentCapabilityDescriptor {
pub id: &'static str,
pub area: &'static str,
pub title: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub platforms: Option<&'static [&'static str]>,
}
pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescriptor; 41] = [
capability("chat", "user", "聊天入口"),
capability("project-supervisor", "agent-runtime", "项目总控 Agent"),
capability("persistent-user-input", "agent-runtime", "持久用户澄清请求"),
capability("file-upload", "user", "上传文件"),
capability("built-in-commands", "agent-runtime", "内置命令调用"),
capability("llm-draft-generation", "agent-runtime", "LLM 草案生成"),
capability(
"provider-web-search",
"agent-runtime",
"Provider 原生联网检索",
),
capability("context-compaction", "agent-runtime", "持久上下文压缩"),
capability("mcp-tools", "agent-runtime", "Runner 托管的 MCP 动态工具"),
capability("task-decomposition", "agent-runtime", "任务拆分"),
capability("orchestration", "agent-runtime", "任务编排"),
capability(
"agent-loop",
"agent-runtime",
"Planner / Generator / Evaluator 循环",
),
capability("task-graph-agenda", "agent-runtime", "任务图议程"),
capability(
"evaluator-repair-routing",
"agent-runtime",
"Evaluator 返工路由",
),
capability("tool-call-budget", "agent-runtime", "工具调用预算"),
capability("multi-agent-collaboration", "agent-runtime", "多智能体协作"),
capability("role-level-collaboration", "agent-runtime", "组内角色协作"),
capability("isolated-subagents", "agent-runtime", "动态隔离子 Agent"),
capability("quality-review", "agent-runtime", "质量评审"),
capability("run-lifecycle", "agent-runtime", "Run 生命周期控制"),
capability(
"repair-loop-carryover",
"agent-runtime",
"返工范围与 Carry-over",
),
capability("short-term-memory", "agent-runtime", "短期记忆"),
capability("long-term-memory", "agent-runtime", "长期记忆"),
capability("conversation-history", "agent-runtime", "对话记录上下文"),
capability("local-artifacts", "local-runtime", "本地产物保存"),
capability("project-checkpoints", "local-runtime", "项目快照与恢复"),
capability("project-index", "local-runtime", "本地项目索引"),
capability(
"repository-startup-context",
"local-runtime",
"仓库启动上下文",
),
capability(
"persistent-process-sessions",
"local-runtime",
"Runner 托管的持久进程会话",
),
capability("local-preview", "local-runtime", "本地 HTTP 预览"),
capability("browser-validation", "local-runtime", "浏览器试玩验证"),
capability("visual-inspection", "local-runtime", "模型视觉检查"),
capability("canvas-project-sync", "local-runtime", "画板项目资源同步"),
capability("developer-window", "dev-runtime", "开发窗口"),
capability("persistent-runner", "dev-runtime", "独立持久 Runner"),
capability(
"command-exec",
"dev-runtime",
"结构化项目命令执行(Linux 工作区沙箱)",
),
platform_capability(
"os-workspace-sandbox",
"dev-runtime",
"Linux OS 强制工作区沙箱",
&["linux"],
),
capability("command-output-read", "dev-runtime", "命令输出分页回查"),
capability("guardrails", "dev-runtime", "权限 Gate"),
capability("project-policy", "dev-runtime", "项目级权限策略"),
capability("trace-log", "dev-runtime", "执行日志"),
];
const fn capability(
id: &'static str,
area: &'static str,
title: &'static str,
) -> GameCreationAgentCapabilityDescriptor {
GameCreationAgentCapabilityDescriptor {
id,
area,
title,
platforms: None,
}
}
const fn platform_capability(
id: &'static str,
area: &'static str,
title: &'static str,
platforms: &'static [&'static str],
) -> GameCreationAgentCapabilityDescriptor {
GameCreationAgentCapabilityDescriptor {
id,
area,
title,
platforms: Some(platforms),
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAppLimitedRunCommandDescriptor {
pub id: &'static str,
pub title: &'static str,
}
pub const GAME_CREATION_APP_LIMITED_RUN_COMMANDS: [GameCreationAppLimitedRunCommandDescriptor; 1] =
[limited_run_command("game.static_smoke", "静态入口自检")];
const fn limited_run_command(
id: &'static str,
title: &'static str,
) -> GameCreationAppLimitedRunCommandDescriptor {
GameCreationAppLimitedRunCommandDescriptor { id, title }
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GameCreationAppAgentGroup {
Design,
Art,
Code,
Balance,
Audio,
Publishing,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GameCreationAppTaskStatus {
Pending,
Running,
WaitingForConfirmation,
Completed,
Failed,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAppTaskState {
pub id: String,
pub title: String,
pub group: GameCreationAppAgentGroup,
pub role: String,
pub status: GameCreationAppTaskStatus,
pub dependencies: Vec<String>,
pub artifacts: Vec<String>,
pub acceptance_criteria: Vec<String>,
}
pub fn new_game_creation_app_seed_tasks() -> Vec<GameCreationAppTaskState> {
vec![
task(
"design-director",
"拆解创作方向",
GameCreationAppAgentGroup::Design,
"Director",
[],
[".agent/spec.md"],
["创作目标、范围和专业组分工明确"],
),
task(
"art-director",
"确定视觉方向与规范图",
GameCreationAppAgentGroup::Art,
"Director",
[],
[
".agent/passes/pass-*/groups/art/director.md",
"assets/art-spec.png",
],
["角色、场景和 UI 的统一视觉方向明确,且已生成可复用的 1:1 图标规范图"],
),
task(
"design-foundation",
"确定玩法规格与界面原型",
GameCreationAppAgentGroup::Design,
"Gameplay",
["design-director", "art-director"],
[
"memory/project.md",
"game/game_design.md",
"assets/ui-prototype.png",
],
[
"核心循环、胜负条件和第一版关卡目标明确,且已基于规范图生成可读的 16:9 横屏界面原型图",
],
),
task(
"balance-director",
"确定数值口径",
GameCreationAppAgentGroup::Balance,
"Director",
["design-foundation"],
[".agent/passes/pass-*/groups/balance/director.md"],
["难度、节奏和得分口径可指导数值表"],
),
task(
"balance-seed",
"生成初版数值",
GameCreationAppAgentGroup::Balance,
"Difficulty",
["balance-director"],
["game/balance.json"],
["速度、生命、得分和难度参数可被程序组读取"],
),
task(
"art-asset-plan",
"生成首版美术素材",
GameCreationAppAgentGroup::Art,
"Asset",
["art-director", "design-foundation"],
["assets/manifest.art.json", "assets/art-spritesheet.png"],
[
"角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记",
],
),
task(
"art-polish",
"检查美术可用性",
GameCreationAppAgentGroup::Art,
"Polish",
["art-asset-plan"],
[".agent/passes/pass-*/groups/art/polish.md"],
["首版资产不阻塞可玩原型和后续画板精修"],
),
task(
"audio-director",
"确定声音方向",
GameCreationAppAgentGroup::Audio,
"Director",
["design-foundation"],
[".agent/passes/pass-*/groups/audio/director.md"],
["BGM 氛围和交互音效边界明确"],
),
task(
"audio-asset-plan",
"规划音乐音效",
GameCreationAppAgentGroup::Audio,
"SFX",
["audio-director"],
["assets/manifest.audio.json"],
["BGM 和核心交互音效需求已明确"],
),
task(
"code-director",
"拆解程序实现",
GameCreationAppAgentGroup::Code,
"Director",
[],
[".agent/passes/pass-*/groups/code/director.md"],
["渲染、输入、状态和数据读取边界明确"],
),
task(
"code-prototype",
"生成可运行原型",
GameCreationAppAgentGroup::Code,
"Code",
["code-director", "balance-seed", "audio-asset-plan"],
["game/"],
["本地 Web 游戏项目可以通过 HTTP server 打开"],
),
task(
"quality-review",
"执行质量评审",
GameCreationAppAgentGroup::Code,
"Review",
["code-prototype"],
[".agent/findings.md", ".agent/run.latest.json"],
["玩法、资产、数值、程序和发布包装通过跨专业组质量评审"],
),
task(
"preview-readiness",
"执行静态自检",
GameCreationAppAgentGroup::Code,
"Preview",
["quality-review"],
[".agent/logs/command.log", ".agent/run.latest.json"],
["HTML 自包含且通过本地静态 smoke"],
),
task(
"preview-playtest",
"预览并试玩验收",
GameCreationAppAgentGroup::Code,
"Playtest",
["publish-package"],
[".agent/logs/preview.log"],
["预览不是空白页,主循环和基础输入可用"],
),
task(
"publish-strategy",
"整理运营定位",
GameCreationAppAgentGroup::Publishing,
"Director",
["preview-readiness"],
[".agent/passes/pass-*/groups/publishing/director.md"],
["标题、卖点、标签和封面方向明确"],
),
task(
"publish-package",
"整理发布包装",
GameCreationAppAgentGroup::Publishing,
"Publish",
["publish-strategy"],
["exports/README.md"],
["标题、简介、标签、封面需求和导出检查已完成"],
),
]
}
fn task<const D: usize, const A: usize, const C: usize>(
id: &str,
title: &str,
group: GameCreationAppAgentGroup,
role: &str,
dependencies: [&str; D],
artifacts: [&str; A],
acceptance_criteria: [&str; C],
) -> GameCreationAppTaskState {
GameCreationAppTaskState {
id: id.to_string(),
title: title.to_string(),
group,
role: role.to_string(),
status: GameCreationAppTaskStatus::Pending,
dependencies: dependencies.map(str::to_string).to_vec(),
artifacts: artifacts.map(str::to_string).to_vec(),
acceptance_criteria: acceptance_criteria.map(str::to_string).to_vec(),
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum GameCreationAppAssetSourceKind {
Uploaded,
Generated,
Canvas,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAppAssetSource {
pub kind: GameCreationAppAssetSourceKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canvas_project_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_object_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation_route: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation_kind: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reference_resource_ids: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAppImageSequenceFrame {
pub image_src: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub object_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_object_id: Option<String>,
pub width: u32,
pub height: u32,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAppAssetManifestEntry {
pub id: String,
pub kind: GameCreationAppAssetKind,
pub media_type: String,
pub local_path: String,
pub source: GameCreationAppAssetSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_sequence_frames: Option<Vec<GameCreationAppImageSequenceFrame>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_sequence_duration_ms: Option<u64>,
pub category: GameCreationAppAssetCategory,
pub tags: Vec<String>,
}
/// 历史 manifest 缺少 `category` / `tags` 时按 `kind` 派生默认值;
/// 显式写入的合法分类必须原样保留,未知分类值**失败关闭**(见下方 `Deserialize` 实现)。
///
/// 这里**刻意不做读时自愈**(落盘 `unclassified` 而 kind 能派生明确分类时改用派生值):
/// 反序列化结果就是落盘原值,「编辑标签」面板要靠它把落盘分类原样回写,否则用户只改标签
/// 也会静默改分类。自愈只属于读显示口径,见 `game_creation_app_asset_effective_category`。
///
/// `deny_unknown_fields` 与顶层 `GameCreationAppManifest` 同一取向:AGC 客户端是
/// 「整结构体反序列化 + 整结构体重新序列化覆盖落盘」,资产级未知字段(例如未来版本的
/// `rotation` / `animations`)一旦被静默丢弃,就会在任意一次写入里被抹掉。顶层有这道门、
/// 资产级没有的话,等于只挡住了一半的静默数据丢失。
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct GameCreationAppAssetManifestEntryWire {
id: String,
kind: String,
media_type: String,
local_path: String,
source: GameCreationAppAssetSource,
#[serde(default)]
image_sequence_frames: Option<Vec<GameCreationAppImageSequenceFrame>>,
#[serde(default)]
image_sequence_duration_ms: Option<u64>,
#[serde(default)]
category: Option<String>,
#[serde(default)]
tags: Option<Vec<String>>,
}
impl<'de> Deserialize<'de> for GameCreationAppAssetManifestEntry {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let wire = GameCreationAppAssetManifestEntryWire::deserialize(deserializer)?;
// 未知 `category` 必须失败关闭,不能退回 `kind` 派生值:反序列化结果就是「编辑标签」
// 面板要原样回写的落盘原值,静默替换成派生值会让「只改标签」变成静默改分类
// (写侧整结构体覆盖落盘,替换值会真的写回文件)。派生只属于缺字段的读兼容,
// 以及读显示口径 `game_creation_app_asset_effective_category`。
let category = match wire.category.as_deref() {
None => {
game_creation_app_asset_category_for_raw_kind(&wire.kind, "manifest.asset.category")
}
Some(raw) => game_creation_app_asset_category_from_str(raw).ok_or_else(|| {
<D::Error as serde::de::Error>::custom(format!(
"未知的素材分类 category = {raw}:本客户端会整结构体重写 manifest,\
无法原样回写该值,因此失败关闭(请升级客户端)"
))
})?,
};
Ok(Self {
id: wire.id,
kind: GameCreationAppAssetKind::parse_with_context(&wire.kind, "manifest.asset.kind"),
media_type: wire.media_type,
local_path: wire.local_path,
source: wire.source,
image_sequence_frames: wire.image_sequence_frames,
image_sequence_duration_ms: wire.image_sequence_duration_ms,
category,
tags: wire.tags.unwrap_or_default(),
})
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GameCreationAppAssetCategory {
UiInteraction,
Character,
Scene,
Audio,
Document,
Unclassified,
}
pub const GAME_CREATION_APP_ASSET_CATEGORIES: [GameCreationAppAssetCategory; 6] = [
GameCreationAppAssetCategory::UiInteraction,
GameCreationAppAssetCategory::Character,
GameCreationAppAssetCategory::Scene,
GameCreationAppAssetCategory::Audio,
GameCreationAppAssetCategory::Document,
GameCreationAppAssetCategory::Unclassified,
];
/// kind 到功能分类的默认映射,**按枚举变体穷举**:少一个变体编译不过,
/// 与 `GameCreationAppAssetKind::ALL` 的一致性由测试 `asset_category_mapping_covers_every_kind`
/// 兜住。这是 kind → 分类的唯一真源,仓库里不再有第二份 canonical 字符串列表。
pub const GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND: &[(
GameCreationAppAssetKind,
GameCreationAppAssetCategory,
)] = &[
(
GameCreationAppAssetKind::Image,
GameCreationAppAssetCategory::Unclassified,
),
(
GameCreationAppAssetKind::Scene,
GameCreationAppAssetCategory::Scene,
),
(
GameCreationAppAssetKind::Character,
GameCreationAppAssetCategory::Character,
),
(
GameCreationAppAssetKind::CharacterAnimation,
GameCreationAppAssetCategory::Character,
),
(
GameCreationAppAssetKind::Icon,
GameCreationAppAssetCategory::UiInteraction,
),
(
GameCreationAppAssetKind::IconSpritesheet,
GameCreationAppAssetCategory::UiInteraction,
),
(
GameCreationAppAssetKind::IconSpec,
GameCreationAppAssetCategory::UiInteraction,
),
(
GameCreationAppAssetKind::UiDesign,
GameCreationAppAssetCategory::UiInteraction,
),
(
GameCreationAppAssetKind::UiDesignDoc,
GameCreationAppAssetCategory::UiInteraction,
),
(
GameCreationAppAssetKind::PublicationMaterial,
GameCreationAppAssetCategory::Unclassified,
),
(
GameCreationAppAssetKind::Spec,
GameCreationAppAssetCategory::Document,
),
(
GameCreationAppAssetKind::Video,
GameCreationAppAssetCategory::Unclassified,
),
(
GameCreationAppAssetKind::Audio,
GameCreationAppAssetCategory::Audio,
),
(
GameCreationAppAssetKind::SoundEffect,
GameCreationAppAssetCategory::Audio,
),
(
GameCreationAppAssetKind::BackgroundMusic,
GameCreationAppAssetCategory::Audio,
),
(
GameCreationAppAssetKind::Font,
GameCreationAppAssetCategory::Document,
),
(
GameCreationAppAssetKind::Document,
GameCreationAppAssetCategory::Document,
),
(
GameCreationAppAssetKind::Code,
GameCreationAppAssetCategory::Unclassified,
),
// 认不出的 kind 一律落「待归类」。
(
GameCreationAppAssetKind::Unknown,
GameCreationAppAssetCategory::Unclassified,
),
];
pub fn game_creation_app_asset_category_from_str(
value: &str,
) -> Option<GameCreationAppAssetCategory> {
match value.trim() {
"ui-interaction" => Some(GameCreationAppAssetCategory::UiInteraction),
"character" => Some(GameCreationAppAssetCategory::Character),
"scene" => Some(GameCreationAppAssetCategory::Scene),
"audio" => Some(GameCreationAppAssetCategory::Audio),
"document" => Some(GameCreationAppAssetCategory::Document),
"unclassified" => Some(GameCreationAppAssetCategory::Unclassified),
_ => None,
}
}
pub fn game_creation_app_asset_category_for_kind(
kind: GameCreationAppAssetKind,
) -> GameCreationAppAssetCategory {
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND
.iter()
.find(|(candidate, _)| *candidate == kind)
.map(|(_, category)| *category)
.unwrap_or(GameCreationAppAssetCategory::Unclassified)
}
/// 解析外部 kind 后再按共享枚举派生分类;内部业务逻辑应传入已解析的枚举。
pub fn game_creation_app_asset_category_for_raw_kind(
raw_kind: &str,
context: &'static str,
) -> GameCreationAppAssetCategory {
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::parse_with_context(
raw_kind, context,
))
}
/// manifest 资产的**有效分类**(读显示口径)。
///
/// 与 TS 侧 `gameCreationAppAssetCategory` 是同一套口径的两份实现,两侧不许各写一份:
/// Rust 侧由本文件的 `EFFECTIVE_CATEGORY_CONTRACT` 决策矩阵用例钉住,TS 侧由
/// `packages/shared/src/contracts/gameCreationApp.test.ts` 的同口径用例钉住。
///
/// 规则:落盘 `category` 是权威值;**唯一例外**是落盘值 `unclassified` 而该资产 `kind`
/// 能派生出明确的非 `unclassified` 分类时采用派生值。这条例外用于自愈历史上被系统误写成
/// `unclassified` 的存量(例如 UI 资产落盘 `unclassified` 而 kind 已是 `ui-design`),
/// 不写迁移脚本且永久生效;`kind` 派生结果本身就是 `unclassified` 的
/// (`image / video / code / publication-material`,以及认不出的 kind)仍信任落盘值。
///
/// kind 认不出时解析边界只收口成 `Unknown` 并留痕(见
/// `GameCreationAppAssetKind::parse_with_context`)。
///
/// 注意分工:本函数只用于**读显示**(UI 栏目、Agent 投影)。回写 manifest 必须用落盘原值
/// (`GameCreationAppAssetManifestEntry.category` 反序列化后就是落盘原值),否则「编辑标签」
/// 面板会把自愈值写回去,把「只改标签」变成静默改分类。
pub fn game_creation_app_asset_effective_category(
kind: GameCreationAppAssetKind,
persisted: GameCreationAppAssetCategory,
) -> GameCreationAppAssetCategory {
if persisted == GameCreationAppAssetCategory::Unclassified {
let derived_from_kind = game_creation_app_asset_category_for_kind(kind);
if derived_from_kind != GameCreationAppAssetCategory::Unclassified {
return derived_from_kind;
}
}
persisted
}
pub fn normalize_game_creation_app_asset_tags(tags: &[String]) -> Vec<String> {
let mut normalized = Vec::new();
for tag in tags {
let tag = tag.trim();
if tag.is_empty() || normalized.iter().any(|existing| existing == tag) {
continue;
}
normalized.push(tag.to_string());
}
normalized
}
pub const GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION: &str = "game-creator-resource-layout.v1";
pub const GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION: u64 = 9_007_199_254_740_991;
pub fn validate_project_resource_canvas_layout_revision(revision: u64) -> Result<(), &'static str> {
if revision > GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION {
return Err("资源布局 revision 超出 JavaScript 安全整数范围");
}
Ok(())
}
fn serialize_project_resource_canvas_layout_revision<S>(
revision: &u64,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
validate_project_resource_canvas_layout_revision(*revision)
.map_err(serde::ser::Error::custom)?;
serializer.serialize_u64(*revision)
}
fn deserialize_project_resource_canvas_layout_revision<'de, D>(
deserializer: D,
) -> Result<u64, D::Error>
where
D: serde::Deserializer<'de>,
{
let revision = u64::deserialize(deserializer)?;
validate_project_resource_canvas_layout_revision(revision).map_err(serde::de::Error::custom)?;
Ok(revision)
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProjectResourceCanvasLayoutMode {
Dependency,
Type,
}
/// 资源画布分区轴:6 类资源资产功能分类 + 末尾独立的「项目版本」栏目。
///
/// 项目版本不是资源资产,6 类资产分类轴对它不适用,因此固定在末尾单独成栏。
/// `Code` / `Art` 是旧四 / 五栏目(扩展名 + mediaType 口径)独有的历史取值,只用于继续
/// 读取既有布局 sidecar;`Document` / `Audio` / `Version` 在新旧口径下同名,不需要别名。
/// 写入侧只写现行 7 个分区之一。
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProjectResourceCanvasSection {
UiInteraction,
Character,
Scene,
Audio,
Document,
Unclassified,
Version,
Code,
Art,
}
/// 现行分区顺序,必须与前端 `PROJECT_RESOURCE_CANVAS_SECTIONS` 保持一致。
pub const PROJECT_RESOURCE_CANVAS_SECTIONS: [ProjectResourceCanvasSection; 7] = [
ProjectResourceCanvasSection::UiInteraction,
ProjectResourceCanvasSection::Character,
ProjectResourceCanvasSection::Scene,
ProjectResourceCanvasSection::Audio,
ProjectResourceCanvasSection::Document,
ProjectResourceCanvasSection::Unclassified,
ProjectResourceCanvasSection::Version,
];
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectResourceCanvasPosition {
pub resource_id: String,
pub section: ProjectResourceCanvasSection,
pub x: i32,
pub y: i32,
pub manually_placed: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectResourceCanvasLayout {
pub schema_version: String,
pub project_id: String,
pub mode: ProjectResourceCanvasLayoutMode,
#[serde(
deserialize_with = "deserialize_project_resource_canvas_layout_revision",
serialize_with = "serialize_project_resource_canvas_layout_revision"
)]
pub revision: u64,
pub positions: Vec<ProjectResourceCanvasPosition>,
pub updated_at: u64,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum UpdateProjectResourceCanvasLayoutStatus {
Updated,
Conflict,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateProjectResourceCanvasLayoutResult {
pub status: UpdateProjectResourceCanvasLayoutStatus,
pub layout: ProjectResourceCanvasLayout,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GameCreationAppPreviewStatus {
Stopped,
Starting,
Running,
Failed,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAppPreviewState {
pub status: GameCreationAppPreviewStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GameCreationAppCommandRunStatus {
Completed,
Failed,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAppCommandRunState {
pub command_id: String,
pub status: GameCreationAppCommandRunStatus,
pub output: String,
pub log_path: String,
pub updated_at: u64,
}
pub const GAME_ITERATION_VERSION_MAX_COUNT: usize = 4096;
pub const GAME_ITERATION_VERSION_MAX_BINDING_COUNT: usize = 4096;
pub const GAME_ITERATION_VERSION_MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum GameIterationVersionCreatedReason {
Initial,
ResourceReplacement,
AgentRevision,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameIterationVersionResourceBinding {
pub slot_id: String,
pub resource_id: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameIterationVersion {
pub version_id: String,
pub parent_version_id: Option<String>,
pub project_revision: u64,
pub resource_bindings: Vec<GameIterationVersionResourceBinding>,
pub created_reason: GameIterationVersionCreatedReason,
pub created_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edit_prompt: Option<String>,
}
fn validate_iteration_version_id(value: &str, label: &str, max_chars: usize) -> Result<(), String> {
if value.is_empty() || value.trim() != value {
return Err(format!("{label}不能为空或包含首尾空白"));
}
if value.chars().count() > max_chars || value.chars().any(char::is_control) {
return Err(format!("{label}无效"));
}
Ok(())
}
pub fn validate_game_iteration_versions(versions: &[GameIterationVersion]) -> Result<(), String> {
if versions.len() > GAME_ITERATION_VERSION_MAX_COUNT {
return Err(format!(
"项目版本最多支持 {GAME_ITERATION_VERSION_MAX_COUNT} 条"
));
}
let mut previous_versions = HashMap::<&str, (u64, u64)>::new();
for (index, version) in versions.iter().enumerate() {
validate_iteration_version_id(&version.version_id, "版本 ID", 128)?;
if previous_versions.contains_key(version.version_id.as_str()) {
return Err(format!("项目版本 ID 重复:{}", version.version_id));
}
if version.project_revision > GAME_ITERATION_VERSION_MAX_SAFE_INTEGER {
return Err(format!(
"项目版本 {} 的 projectRevision 超出 JavaScript 安全整数范围",
version.version_id
));
}
if version.created_at > GAME_ITERATION_VERSION_MAX_SAFE_INTEGER {
return Err(format!(
"项目版本 {} 的 createdAt 超出 JavaScript 安全整数范围",
version.version_id
));
}
if let Some(edit_prompt) = version.edit_prompt.as_deref() {
if edit_prompt.trim().is_empty()
|| edit_prompt.chars().count() > 32_000
|| edit_prompt.chars().any(|character| {
character.is_control() && !matches!(character, '\n' | '\r' | '\t')
})
{
return Err(format!(
"项目版本 {} 的 editPrompt 必须在 1..=32000 字符内且不能包含非法控制字符",
version.version_id
));
}
}
if index == 0 {
if version.parent_version_id.is_some()
|| version.created_reason != GameIterationVersionCreatedReason::Initial
{
return Err(format!(
"项目版本 {} 的首条记录必须是无父版本的 initial 版本",
version.version_id
));
}
} else {
if version.created_reason == GameIterationVersionCreatedReason::Initial {
return Err(format!(
"项目版本 {} 只有首个版本可以使用 initial 创建原因",
version.version_id
));
}
let Some(parent_version_id) = &version.parent_version_id else {
return Err(format!(
"项目版本 {} 只有首个 initial 版本可以没有父版本",
version.version_id
));
};
validate_iteration_version_id(parent_version_id, "父版本 ID", 128)?;
let Some((parent_revision, parent_created_at)) =
previous_versions.get(parent_version_id.as_str())
else {
return Err(format!(
"项目版本 {} 的父版本必须先于子版本存在",
version.version_id
));
};
if version.project_revision <= *parent_revision {
return Err(format!(
"项目版本 {} 的 projectRevision 必须大于父版本",
version.version_id
));
}
if version.created_at < *parent_created_at {
return Err(format!(
"项目版本 {} 的 createdAt 不能早于父版本",
version.version_id
));
}
}
if version.resource_bindings.len() > GAME_ITERATION_VERSION_MAX_BINDING_COUNT {
return Err(format!(
"项目版本 {} 的资源绑定最多支持 {GAME_ITERATION_VERSION_MAX_BINDING_COUNT} 项",
version.version_id
));
}
let mut slot_ids = HashSet::new();
for binding in &version.resource_bindings {
validate_iteration_version_id(&binding.slot_id, "版本资源槽位 ID", 256)?;
validate_iteration_version_id(&binding.resource_id, "版本资源 ID", 512)?;
if !slot_ids.insert(binding.slot_id.as_str()) {
return Err(format!(
"项目版本 {} 的资源槽位重复:{}",
version.version_id, binding.slot_id
));
}
}
previous_versions.insert(
version.version_id.as_str(),
(version.project_revision, version.created_at),
);
}
Ok(())
}
/// 本地项目 `.agent/manifest.json` 的持久化结构。
///
/// `deny_unknown_fields` 是**前向兼容的失败关闭**,不是多余的严格性:AGC 客户端读写 manifest 的方式是
/// 「整结构体反序列化 + 整结构体重新序列化覆盖落盘」(见 `src-tauri` 的 `write_manifest_locked`),
/// 且写回只带本结构体已知的字段。所以一旦放行未知顶层字段,「读一次 + 任意一次写」就会把未来版本
/// 新增的顶层字段静默抹掉——这是持久化数据上的静默数据丢失,而不是一次可以忽略的宽松解析。
///
/// 为什么不选「保留未知字段 round-trip」:`#[serde(flatten)]` 只能覆盖加了它的那一层,`tasks` /
/// `assets` / `versions` / `preview` / `commandRuns` 内部的未来新增字段仍会被抹掉,等于给出一层
/// 「顶层安全、嵌套照样丢」的假安全感;而且写侧本来就会用 `skip_serializing_if` 把已知字段归一化,
/// 于是落盘结果会变成「新字段原样 + 旧字段被本客户端规范化」的混合体,比直接报错更难排查。
///
/// 代价与配套:未知顶层字段本身就是「这个文件比本客户端新」的信号。客户端读到直接报错并拒绝改写,
/// 由调用方提示升级;同一读路径上还有 `schemaVersion` 的失败关闭门,两者互补——`schemaVersion` 管
/// 「文件明说自己是新版本」,本属性管「版本号没升但字段已经变了」。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct GameCreationAppManifest {
pub schema_version: String,
pub project_id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tasks: Vec<GameCreationAppTaskState>,
pub assets: Vec<GameCreationAppAssetManifestEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<GameCreationAppPreviewState>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub command_runs: Vec<GameCreationAppCommandRunState>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub versions: Vec<GameIterationVersion>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub godot_project_root: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cocos_project_root: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAgentToolCallTrace {
pub tool_id: String,
pub status: String,
pub input_paths: Vec<String>,
pub output_paths: Vec<String>,
pub summary: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAgentRunStep {
pub pass: u8,
pub agent: String,
#[serde(default)]
pub phase: String,
#[serde(default)]
pub task_id: Option<String>,
#[serde(default)]
pub group: Option<GameCreationAppAgentGroup>,
#[serde(default)]
pub role: Option<String>,
pub status: String,
pub input_paths: Vec<String>,
pub output_paths: Vec<String>,
pub summary: String,
pub tool_calls: Vec<GameCreationAgentToolCallTrace>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAgentArtifactTrace {
pub path: String,
pub size_bytes: u64,
pub checksum: String,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAgentRepairRouteTrace {
pub issue: String,
pub task_ids: Vec<String>,
pub reason: String,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAgentRunTaskGraphTrace {
pub goal: String,
pub ready_task_ids: Vec<String>,
pub active_task_ids: Vec<String>,
pub carried_task_ids: Vec<String>,
pub repair_focus: Vec<String>,
#[serde(default)]
pub repair_routes: Vec<GameCreationAgentRepairRouteTrace>,
pub tasks: Vec<GameCreationAppTaskState>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAgentPassPlanTrace {
pub pass: u8,
pub mode: String,
pub summary: String,
pub active_task_ids: Vec<String>,
pub carried_task_ids: Vec<String>,
pub dependency_waves: Vec<Vec<String>>,
pub repair_focus: Vec<String>,
#[serde(default)]
pub repair_routes: Vec<GameCreationAgentRepairRouteTrace>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAgentRunTrace {
pub schema_version: String,
pub run_id: String,
pub command_id: String,
pub status: String,
#[serde(default)]
pub lifecycle_status: Option<String>,
pub passes: u8,
#[serde(default = "default_game_creation_agent_run_max_passes")]
pub max_passes: u8,
#[serde(default)]
pub tool_call_count: u16,
#[serde(default = "default_game_creation_agent_tool_call_max")]
pub max_tool_calls: u16,
#[serde(default = "default_game_creation_agent_stop_reason")]
pub stop_reason: String,
pub goal: String,
pub coordination: String,
pub steps: Vec<GameCreationAgentRunStep>,
pub artifacts: Vec<GameCreationAgentArtifactTrace>,
#[serde(default)]
pub task_graph: GameCreationAgentRunTaskGraphTrace,
#[serde(default)]
pub pass_plans: Vec<GameCreationAgentPassPlanTrace>,
pub next_step: String,
pub error: Option<String>,
pub updated_at: u64,
}
fn default_game_creation_agent_run_max_passes() -> u8 {
GAME_CREATION_AGENT_RUN_MAX_PASSES
}
fn default_game_creation_agent_tool_call_max() -> u16 {
GAME_CREATION_AGENT_TOOL_CALL_MAX
}
fn default_game_creation_agent_stop_reason() -> String {
"unknown".to_string()
}
pub fn new_game_creation_app_manifest(
project_id: impl Into<String>,
name: impl Into<String>,
) -> GameCreationAppManifest {
GameCreationAppManifest {
schema_version: GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION.to_string(),
project_id: project_id.into(),
name: name.into(),
goal: None,
tasks: new_game_creation_app_seed_tasks(),
assets: Vec::new(),
preview: None,
command_runs: Vec::new(),
versions: Vec::new(),
godot_project_root: None,
cocos_project_root: None,
}
}
pub fn select_game_creation_app_ready_tasks(
manifest: &GameCreationAppManifest,
) -> Vec<GameCreationAppTaskState> {
let seed_tasks;
let tasks = if manifest.tasks.is_empty() {
seed_tasks = new_game_creation_app_seed_tasks();
&seed_tasks
} else {
&manifest.tasks
};
let completed = tasks
.iter()
.filter(|task| task.status == GameCreationAppTaskStatus::Completed)
.map(|task| task.id.as_str())
.collect::<HashSet<_>>();
tasks
.iter()
.filter(|task| {
task.status == GameCreationAppTaskStatus::Pending
&& task
.dependencies
.iter()
.all(|dependency| completed.contains(dependency.as_str()))
})
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn command_contract_keeps_expected_permissions() {
assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 67);
assert_eq!(
GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "godot.editor.execute")
.unwrap()
.permission,
GameCreationAppPermission::Confirm
);
let command_ids = GAME_CREATION_APP_COMMANDS
.iter()
.map(|command| command.id)
.collect::<Vec<_>>();
let limited_index = command_ids
.iter()
.position(|command_id| *command_id == "command.run_limited")
.expect("command.run_limited should exist");
let exec_index = command_ids
.iter()
.position(|command_id| *command_id == "command.exec")
.expect("command.exec should exist");
let output_read_index = command_ids
.iter()
.position(|command_id| *command_id == "command.output_read")
.expect("command.output_read should exist");
let start_index = command_ids
.iter()
.position(|command_id| *command_id == "command.start")
.expect("command.start should exist");
let poll_index = command_ids
.iter()
.position(|command_id| *command_id == "command.poll")
.expect("command.poll should exist");
let stdin_index = command_ids
.iter()
.position(|command_id| *command_id == "command.stdin")
.expect("command.stdin should exist");
let terminate_index = command_ids
.iter()
.position(|command_id| *command_id == "command.terminate")
.expect("command.terminate should exist");
assert_eq!(exec_index, limited_index + 1);
assert_eq!(output_read_index, exec_index + 1);
assert_eq!(start_index, output_read_index + 1);
assert_eq!(poll_index, start_index + 1);
assert_eq!(stdin_index, poll_index + 1);
assert_eq!(terminate_index, stdin_index + 1);
let help = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "help.show")
.expect("command should exist");
assert_eq!(help.permission, GameCreationAppPermission::Auto);
let command = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "command.run_limited")
.expect("command should exist");
assert_eq!(command.permission, GameCreationAppPermission::Confirm);
let project_rename = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "project.rename")
.expect("project.rename should exist");
assert_eq!(
project_rename.permission,
GameCreationAppPermission::Confirm
);
let command_exec = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "command.exec")
.expect("command.exec should exist");
assert_eq!(command_exec.permission, GameCreationAppPermission::Confirm);
let command_output_read = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "command.output_read")
.expect("command.output_read should exist");
assert_eq!(
command_output_read.permission,
GameCreationAppPermission::Auto
);
let command_start = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "command.start")
.expect("command.start should exist");
assert_eq!(command_start.permission, GameCreationAppPermission::Confirm);
let command_poll = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "command.poll")
.expect("command.poll should exist");
assert_eq!(command_poll.permission, GameCreationAppPermission::Auto);
let command_stdin = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "command.stdin")
.expect("command.stdin should exist");
assert_eq!(command_stdin.permission, GameCreationAppPermission::Confirm);
let command_terminate = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "command.terminate")
.expect("command.terminate should exist");
assert_eq!(
command_terminate.permission,
GameCreationAppPermission::Confirm
);
let project_patchset = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "project.patchset")
.expect("project.patchset command should exist");
assert_eq!(
project_patchset.permission,
GameCreationAppPermission::Confirm
);
let project_git_inspect = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "project.git_inspect")
.expect("project.git_inspect command should exist");
assert_eq!(
project_git_inspect.permission,
GameCreationAppPermission::Auto
);
let project_git_commit = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "project.git_commit")
.expect("project.git_commit command should exist");
assert_eq!(
project_git_commit.permission,
GameCreationAppPermission::Confirm
);
assert_eq!(
command_ids
.iter()
.position(|command_id| *command_id == "project.git_commit"),
command_ids
.iter()
.position(|command_id| *command_id == "project.git_inspect")
.map(|index| index + 1)
);
let project_verify = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "project.verify")
.expect("project.verify command should exist");
assert_eq!(
project_verify.permission,
GameCreationAppPermission::Confirm
);
let generate = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "game.generate_draft")
.expect("command should exist");
assert_eq!(generate.permission, GameCreationAppPermission::Confirm);
let run_local = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "game.run_local")
.expect("command should exist");
assert_eq!(run_local.permission, GameCreationAppPermission::Confirm);
let export_package = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "project.export_package")
.expect("command should exist");
assert_eq!(
export_package.permission,
GameCreationAppPermission::Confirm
);
let export_list = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "project.export_list")
.expect("command should exist");
assert_eq!(export_list.permission, GameCreationAppPermission::Auto);
let status = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "project.status")
.expect("command should exist");
assert_eq!(status.permission, GameCreationAppPermission::Auto);
let task_list = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "task.list")
.expect("command should exist");
assert_eq!(task_list.permission, GameCreationAppPermission::Auto);
let task_create = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "task.create")
.expect("command should exist");
assert_eq!(task_create.permission, GameCreationAppPermission::Confirm);
let trace_read = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "agent.trace_read")
.expect("command should exist");
assert_eq!(trace_read.permission, GameCreationAppPermission::Auto);
let run_status = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "agent.run_status")
.expect("command should exist");
assert_eq!(run_status.permission, GameCreationAppPermission::Auto);
for command_id in [
"agent.kill",
"agent.retry",
"agent.resume",
"agent.delegate",
"agent.spawn_isolated",
"agent.schedule_ready",
] {
let lifecycle_command = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == command_id)
.expect("command should exist");
assert_eq!(
lifecycle_command.permission,
GameCreationAppPermission::Confirm
);
}
let preview_validate = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "preview.validate")
.expect("preview.validate command should exist");
assert_eq!(preview_validate.permission, GameCreationAppPermission::Auto);
let agent_capabilities = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "agent.capabilities")
.expect("command should exist");
assert_eq!(
agent_capabilities.permission,
GameCreationAppPermission::Auto
);
let agent_audit = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "agent.audit")
.expect("command should exist");
assert_eq!(agent_audit.permission, GameCreationAppPermission::Auto);
let llm_config_check = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "llm.config_check")
.expect("command should exist");
assert_eq!(llm_config_check.permission, GameCreationAppPermission::Auto);
let mcp_call = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "mcp.call")
.expect("mcp.call should exist");
assert_eq!(mcp_call.permission, GameCreationAppPermission::Auto);
let asset_list = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "asset.list")
.expect("command should exist");
assert_eq!(asset_list.permission, GameCreationAppPermission::Auto);
let asset_register = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "asset.register")
.expect("command should exist");
assert_eq!(
asset_register.permission,
GameCreationAppPermission::Confirm
);
let image_inspect = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "image.inspect")
.expect("image.inspect command should exist");
assert_eq!(image_inspect.permission, GameCreationAppPermission::Auto);
let preview_open = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "preview.open")
.expect("command should exist");
assert_eq!(preview_open.permission, GameCreationAppPermission::Confirm);
let preview_status = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "preview.status")
.expect("command should exist");
assert_eq!(preview_status.permission, GameCreationAppPermission::Auto);
let read = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "file.read")
.expect("command should exist");
assert_eq!(read.permission, GameCreationAppPermission::Auto);
let delete = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "file.delete")
.expect("file.delete command should exist");
assert_eq!(delete.permission, GameCreationAppPermission::Confirm);
let memory_read = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "memory.read")
.expect("command should exist");
assert_eq!(memory_read.permission, GameCreationAppPermission::Auto);
for command_id in ["conversation.read", "conversation.write"] {
let conversation_command = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == command_id)
.expect("command should exist");
assert_eq!(
conversation_command.permission,
GameCreationAppPermission::Auto
);
}
let canvas_project_open = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "canvas.project_open")
.expect("command should exist");
assert_eq!(
canvas_project_open.permission,
GameCreationAppPermission::Confirm
);
let canvas_project_sync = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "canvas.project_sync")
.expect("command should exist");
assert_eq!(
canvas_project_sync.permission,
GameCreationAppPermission::Confirm
);
let canvas_asset_generate = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "canvas.asset_generate")
.expect("command should exist");
assert_eq!(
canvas_asset_generate.permission,
GameCreationAppPermission::Confirm
);
let canvas_export_import = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "canvas.export_import")
.expect("command should exist");
assert_eq!(
canvas_export_import.permission,
GameCreationAppPermission::Confirm
);
}
#[test]
fn capabilities_cover_standard_agent_runtime_needs() {
assert_eq!(GAME_CREATION_AGENT_CAPABILITIES.len(), 41);
let ids = GAME_CREATION_AGENT_CAPABILITIES
.iter()
.map(|capability| capability.id)
.collect::<Vec<_>>();
for expected in [
"chat",
"project-supervisor",
"persistent-user-input",
"file-upload",
"provider-web-search",
"mcp-tools",
"task-decomposition",
"orchestration",
"agent-loop",
"task-graph-agenda",
"evaluator-repair-routing",
"tool-call-budget",
"multi-agent-collaboration",
"role-level-collaboration",
"run-lifecycle",
"repair-loop-carryover",
"short-term-memory",
"long-term-memory",
"conversation-history",
"persistent-process-sessions",
"canvas-project-sync",
"local-preview",
"visual-inspection",
"developer-window",
"command-exec",
"os-workspace-sandbox",
"command-output-read",
] {
assert!(ids.contains(&expected), "missing {expected}");
}
let persistent_user_input = GAME_CREATION_AGENT_CAPABILITIES
.iter()
.find(|capability| capability.id == "persistent-user-input")
.expect("persistent-user-input capability should exist");
assert_eq!(persistent_user_input.area, "agent-runtime");
assert_eq!(persistent_user_input.title, "持久用户澄清请求");
let command_exec = GAME_CREATION_AGENT_CAPABILITIES
.iter()
.find(|capability| capability.id == "command-exec")
.expect("command-exec capability should exist");
assert_eq!(command_exec.area, "dev-runtime");
assert_eq!(command_exec.title, "结构化项目命令执行(Linux 工作区沙箱)");
let workspace_sandbox = GAME_CREATION_AGENT_CAPABILITIES
.iter()
.find(|capability| capability.id == "os-workspace-sandbox")
.expect("os-workspace-sandbox capability should exist");
assert_eq!(workspace_sandbox.area, "dev-runtime");
assert_eq!(workspace_sandbox.title, "Linux OS 强制工作区沙箱");
assert_eq!(workspace_sandbox.platforms, Some(&["linux"][..]));
let command_output_read = GAME_CREATION_AGENT_CAPABILITIES
.iter()
.find(|capability| capability.id == "command-output-read")
.expect("command-output-read capability should exist");
assert_eq!(command_output_read.area, "dev-runtime");
assert_eq!(command_output_read.title, "命令输出分页回查");
let visual_inspection = GAME_CREATION_AGENT_CAPABILITIES
.iter()
.find(|capability| capability.id == "visual-inspection")
.expect("visual-inspection capability should exist");
assert_eq!(visual_inspection.area, "local-runtime");
assert_eq!(visual_inspection.title, "模型视觉检查");
let persistent_process_sessions = GAME_CREATION_AGENT_CAPABILITIES
.iter()
.find(|capability| capability.id == "persistent-process-sessions")
.expect("persistent-process-sessions capability should exist");
assert_eq!(persistent_process_sessions.area, "local-runtime");
assert_eq!(
persistent_process_sessions.title,
"Runner 托管的持久进程会话"
);
assert_eq!(
GAME_CREATION_AGENT_CAPABILITIES
.iter()
.find(|capability| capability.id == "conversation-history")
.map(|capability| capability.title),
Some("对话记录上下文")
);
}
#[test]
fn limited_run_commands_include_static_game_smoke() {
let command = GAME_CREATION_APP_LIMITED_RUN_COMMANDS
.iter()
.find(|command| command.id == "game.static_smoke")
.expect("static smoke command should exist");
assert_eq!(command.title, "静态入口自检");
}
#[test]
fn agent_run_trace_uses_camel_case_tool_calls() {
let trace = GameCreationAgentRunTrace {
schema_version: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION.to_string(),
run_id: "run-1".to_string(),
command_id: "game.generate_draft".to_string(),
status: "preview-running".to_string(),
lifecycle_status: Some("running".to_string()),
passes: 2,
max_passes: GAME_CREATION_AGENT_RUN_MAX_PASSES,
tool_call_count: 1,
max_tool_calls: GAME_CREATION_AGENT_TOOL_CALL_MAX,
stop_reason: "preview-running".to_string(),
goal: "像素动作".to_string(),
coordination: "filesystem".to_string(),
next_step: "manual-playtest".to_string(),
error: None,
updated_at: 123,
artifacts: vec![
GameCreationAgentArtifactTrace {
path: ".agent/run.latest.json".to_string(),
size_bytes: 100,
checksum: "fnv1a64:abc".to_string(),
},
GameCreationAgentArtifactTrace {
path: "game/index.html".to_string(),
size_bytes: 200,
checksum: "fnv1a64:def".to_string(),
},
],
steps: vec![GameCreationAgentRunStep {
pass: 2,
agent: "Preview".to_string(),
phase: "preview".to_string(),
task_id: Some("preview-playtest".to_string()),
group: Some(GameCreationAppAgentGroup::Code),
role: Some("Playtest".to_string()),
status: "running".to_string(),
input_paths: vec!["game/index.html".to_string()],
output_paths: vec![".agent/manifest.json".to_string()],
summary: "本地 HTTP 预览已启动".to_string(),
tool_calls: vec![GameCreationAgentToolCallTrace {
tool_id: "preview.start".to_string(),
status: "running".to_string(),
input_paths: vec!["game/index.html".to_string()],
output_paths: vec![".agent/manifest.json".to_string()],
summary: "127.0.0.1".to_string(),
}],
}],
task_graph: GameCreationAgentRunTaskGraphTrace {
goal: "像素动作".to_string(),
ready_task_ids: vec!["preview-playtest".to_string()],
active_task_ids: vec!["preview-playtest".to_string()],
carried_task_ids: vec!["preview-readiness".to_string()],
repair_focus: vec![],
repair_routes: vec![GameCreationAgentRepairRouteTrace {
issue: "preview smoke failed".to_string(),
task_ids: vec!["preview-playtest".to_string()],
reason: "code-runtime".to_string(),
}],
tasks: new_game_creation_app_seed_tasks(),
},
pass_plans: vec![GameCreationAgentPassPlanTrace {
pass: 2,
mode: "repair".to_string(),
summary: "第 2 轮只返工预览试玩".to_string(),
active_task_ids: vec!["preview-playtest".to_string()],
carried_task_ids: vec!["preview-readiness".to_string()],
dependency_waves: vec![vec!["preview-playtest".to_string()]],
repair_focus: vec!["preview smoke failed".to_string()],
repair_routes: vec![GameCreationAgentRepairRouteTrace {
issue: "preview smoke failed".to_string(),
task_ids: vec!["preview-playtest".to_string()],
reason: "code-runtime".to_string(),
}],
}],
};
let value = serde_json::to_value(trace).expect("trace json");
assert_eq!(
value["schemaVersion"],
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION
);
assert_eq!(value["maxPasses"], GAME_CREATION_AGENT_RUN_MAX_PASSES);
assert_eq!(value["toolCallCount"], 1);
assert_eq!(value["maxToolCalls"], GAME_CREATION_AGENT_TOOL_CALL_MAX);
assert_eq!(value["stopReason"], "preview-running");
assert_eq!(value["artifacts"][0]["checksum"], "fnv1a64:abc");
assert_eq!(value["taskGraph"]["activeTaskIds"][0], "preview-playtest");
assert_eq!(
value["taskGraph"]["repairRoutes"][0]["taskIds"][0],
"preview-playtest"
);
assert_eq!(value["passPlans"][0]["mode"], "repair");
assert_eq!(
value["passPlans"][0]["dependencyWaves"][0][0],
"preview-playtest"
);
assert_eq!(value["steps"][0]["taskId"], "preview-playtest");
assert_eq!(value["steps"][0]["phase"], "preview");
assert_eq!(value["steps"][0]["toolCalls"][0]["toolId"], "preview.start");
}
#[test]
fn seed_manifest_includes_professional_group_tasks() {
let manifest = new_game_creation_app_manifest("project-1", "像素动作原型");
assert_eq!(manifest.godot_project_root, None);
let ids = manifest
.tasks
.iter()
.map(|task| task.id.as_str())
.collect::<Vec<_>>();
assert_eq!(
ids,
vec![
"design-director",
"art-director",
"design-foundation",
"balance-director",
"balance-seed",
"art-asset-plan",
"art-polish",
"audio-director",
"audio-asset-plan",
"code-director",
"code-prototype",
"quality-review",
"preview-readiness",
"preview-playtest",
"publish-strategy",
"publish-package",
]
);
assert_eq!(manifest.tasks[0].group, GameCreationAppAgentGroup::Design);
for director_id in ["design-director", "art-director", "code-director"] {
let director = manifest
.tasks
.iter()
.find(|task| task.id == director_id)
.expect("director task");
assert!(director.dependencies.is_empty());
}
let design = manifest
.tasks
.iter()
.find(|task| task.id == "design-foundation")
.expect("design foundation task");
assert_eq!(design.title, "确定玩法规格与界面原型");
assert_eq!(design.group, GameCreationAppAgentGroup::Design);
assert_eq!(design.role, "Gameplay");
assert_eq!(design.status, GameCreationAppTaskStatus::Pending);
assert_eq!(design.dependencies, ["design-director", "art-director"]);
assert_eq!(
design.artifacts,
[
"memory/project.md",
"game/game_design.md",
"assets/ui-prototype.png"
]
);
assert_eq!(
design.acceptance_criteria,
[
"核心循环、胜负条件和第一版关卡目标明确,且已基于规范图生成可读的 16:9 横屏界面原型图"
]
);
let art = manifest
.tasks
.iter()
.find(|task| task.id == "art-asset-plan")
.expect("art asset task");
assert_eq!(art.title, "生成首版美术素材");
assert_eq!(art.group, GameCreationAppAgentGroup::Art);
assert_eq!(art.role, "Asset");
assert_eq!(art.status, GameCreationAppTaskStatus::Pending);
assert_eq!(art.dependencies, ["art-director", "design-foundation"]);
assert_eq!(
art.artifacts,
["assets/manifest.art.json", "assets/art-spritesheet.png"]
);
assert_eq!(
art.acceptance_criteria,
[
"角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记"
]
);
let code_prototype = manifest
.tasks
.iter()
.find(|task| task.id == "code-prototype")
.expect("code prototype task");
assert_eq!(
code_prototype.dependencies,
["code-director", "balance-seed", "audio-asset-plan"]
);
}
#[test]
fn ready_tasks_follow_completed_dependencies() {
let mut manifest = new_game_creation_app_manifest("project-1", "像素动作原型");
assert_eq!(
select_game_creation_app_ready_tasks(&manifest)
.iter()
.map(|task| task.id.as_str())
.collect::<Vec<_>>(),
vec!["design-director", "art-director", "code-director"]
);
for director_id in ["design-director", "art-director", "code-director"] {
manifest
.tasks
.iter_mut()
.find(|task| task.id == director_id)
.unwrap()
.status = GameCreationAppTaskStatus::Completed;
}
assert_eq!(
select_game_creation_app_ready_tasks(&manifest)
.iter()
.map(|task| task.id.as_str())
.collect::<Vec<_>>(),
vec!["design-foundation"]
);
manifest
.tasks
.iter_mut()
.find(|task| task.id == "design-foundation")
.unwrap()
.status = GameCreationAppTaskStatus::Completed;
assert_eq!(
select_game_creation_app_ready_tasks(&manifest)
.iter()
.map(|task| task.id.as_str())
.collect::<Vec<_>>(),
vec!["balance-director", "art-asset-plan", "audio-director"]
);
for task_id in ["balance-director", "audio-director"] {
manifest
.tasks
.iter_mut()
.find(|task| task.id == task_id)
.unwrap()
.status = GameCreationAppTaskStatus::Completed;
}
assert_eq!(
select_game_creation_app_ready_tasks(&manifest)
.iter()
.map(|task| task.id.as_str())
.collect::<Vec<_>>(),
vec!["balance-seed", "art-asset-plan", "audio-asset-plan"]
);
for task_id in ["balance-seed", "audio-asset-plan"] {
manifest
.tasks
.iter_mut()
.find(|task| task.id == task_id)
.unwrap()
.status = GameCreationAppTaskStatus::Completed;
}
assert_eq!(
select_game_creation_app_ready_tasks(&manifest)
.iter()
.map(|task| task.id.as_str())
.collect::<Vec<_>>(),
vec!["art-asset-plan", "code-prototype"]
);
// code-prototype is ready even while both the art asset and polish
// tasks are still pending; the final completion gate checks the real
// art resources later.
assert_eq!(
manifest
.tasks
.iter()
.find(|task| task.id == "art-asset-plan")
.unwrap()
.status,
GameCreationAppTaskStatus::Pending
);
assert_eq!(
manifest
.tasks
.iter()
.find(|task| task.id == "art-polish")
.unwrap()
.status,
GameCreationAppTaskStatus::Pending
);
for task_id in ["art-asset-plan", "art-polish"] {
manifest
.tasks
.iter_mut()
.find(|task| task.id == task_id)
.unwrap()
.status = GameCreationAppTaskStatus::Completed;
}
assert_eq!(
select_game_creation_app_ready_tasks(&manifest)
.iter()
.map(|task| task.id.as_str())
.collect::<Vec<_>>(),
vec!["code-prototype"]
);
}
#[test]
fn seed_task_graph_is_acyclic_and_references_known_dependencies() {
let tasks = new_game_creation_app_seed_tasks();
let known_ids = tasks
.iter()
.map(|task| task.id.as_str())
.collect::<HashSet<_>>();
assert!(tasks.iter().all(|task| {
task.dependencies
.iter()
.all(|dependency| known_ids.contains(dependency.as_str()))
}));
let mut remaining = tasks
.iter()
.map(|task| {
(
task.id.as_str(),
task.dependencies
.iter()
.map(String::as_str)
.collect::<HashSet<_>>(),
)
})
.collect::<Vec<_>>();
let mut visited = 0;
while !remaining.is_empty() {
let ready_ids = remaining
.iter()
.filter(|(_, dependencies)| dependencies.is_empty())
.map(|(id, _)| *id)
.collect::<Vec<_>>();
assert!(!ready_ids.is_empty(), "seed task graph contains a cycle");
remaining.retain(|(id, _)| !ready_ids.contains(id));
visited += ready_ids.len();
for (_, dependencies) in &mut remaining {
for ready_id in &ready_ids {
dependencies.remove(ready_id);
}
}
}
assert_eq!(visited, tasks.len());
}
#[test]
fn manifest_uses_camel_case_and_canvas_source() {
let mut manifest = new_game_creation_app_manifest("project-1", "像素动作原型");
manifest.godot_project_root = Some("game-source".to_string());
manifest.preview = Some(GameCreationAppPreviewState {
status: GameCreationAppPreviewStatus::Running,
url: Some("http://127.0.0.1:3001/".to_string()),
port: Some(3001),
});
manifest.command_runs.push(GameCreationAppCommandRunState {
command_id: "game.static_smoke".to_string(),
status: GameCreationAppCommandRunStatus::Completed,
output: "ok".to_string(),
log_path: ".agent/logs/command.log".to_string(),
updated_at: 123,
});
manifest.versions.push(GameIterationVersion {
version_id: "version-1".to_string(),
parent_version_id: None,
project_revision: 7,
resource_bindings: vec![GameIterationVersionResourceBinding {
slot_id: "player".to_string(),
resource_id: "asset-player".to_string(),
}],
created_reason: GameIterationVersionCreatedReason::Initial,
created_at: 456,
edit_prompt: None,
});
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: "asset-player".to_string(),
kind: GameCreationAppAssetKind::Character,
media_type: "image".to_string(),
local_path: "assets/images/player.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: GameCreationAppAssetCategory::Character,
tags: vec!["主角".to_string()],
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("canvas-project-1".to_string()),
resource_id: Some("resource-1".to_string()),
asset_object_id: Some("asset-object-1".to_string()),
task_id: Some("task-1".to_string()),
prompt: Some("像素风主角".to_string()),
model: Some("image-model".to_string()),
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
});
let payload = serde_json::to_value(manifest).expect("manifest should serialize");
assert_eq!(
payload["schemaVersion"],
json!(GAME_CREATION_APP_MANIFEST_SCHEMA_VERSION)
);
assert_eq!(payload["assets"][0]["mediaType"], json!("image"));
assert_eq!(payload["assets"][0]["category"], json!("character"));
assert_eq!(payload["assets"][0]["tags"], json!(["主角"]));
assert_eq!(payload["assets"][0]["source"]["kind"], json!("canvas"));
assert_eq!(payload["preview"]["status"], json!("running"));
assert_eq!(
payload["commandRuns"][0]["commandId"],
json!("game.static_smoke")
);
assert_eq!(
payload["assets"][0]["source"]["canvasProjectId"],
json!("canvas-project-1")
);
assert_eq!(payload["versions"][0]["versionId"], json!("version-1"));
assert_eq!(
payload["versions"][0]["resourceBindings"][0]["slotId"],
json!("player")
);
assert_eq!(payload["versions"][0]["createdReason"], json!("initial"));
assert_eq!(payload["godotProjectRoot"], json!("game-source"));
}
/// 未知顶层字段必须失败关闭。这条契约级断言钉住的是本结构体本身的取向,而不是某个调用方:
/// AGC 客户端读写 manifest 的方式是「整结构体反序列化 + 整结构体重新序列化覆盖落盘」,
/// 一旦放行未知顶层字段,「读一次 + 任意一次写」就会静默抹掉未来版本新增的字段。
#[test]
fn manifest_rejects_unknown_top_level_fields() {
let mut manifest = new_game_creation_app_manifest("project-1", "像素动作原型");
manifest.goal = Some("做一个像素动作原型".to_string());
let mut payload = serde_json::to_value(&manifest).expect("manifest should serialize");
payload["futureTopLevelField"] = json!({ "addedBy": "a-newer-client" });
let error = serde_json::from_value::<GameCreationAppManifest>(payload)
.expect_err("an unknown manifest top-level field must fail closed");
assert!(
error.to_string().contains("futureTopLevelField"),
"unexpected error: {error}"
);
// 已知字段(含可选字段)必须照旧往返:拒绝未知字段不得顺手把已知字段一起拒掉。
let round_tripped: GameCreationAppManifest = serde_json::from_value(
serde_json::to_value(&manifest).expect("manifest should serialize"),
)
.expect("known manifest fields must still deserialize");
assert_eq!(round_tripped, manifest);
}
fn asset_entry_json(kind: &str) -> serde_json::Value {
json!({
"id": "asset-1",
"kind": kind,
"mediaType": "image/png",
"localPath": "assets/hero.png",
"source": { "kind": "uploaded" },
})
}
fn asset_entry_from_json(value: serde_json::Value) -> GameCreationAppAssetManifestEntry {
serde_json::from_value(value).expect("asset entry should deserialize")
}
#[test]
fn manifest_unknown_kind_is_preserved_as_unknown() {
let entry = asset_entry_from_json(asset_entry_json("future-kind"));
assert_eq!(entry.kind, GameCreationAppAssetKind::Unknown);
let serialized = serde_json::to_value(entry).expect("unknown asset entry serializes");
assert_eq!(serialized["kind"], "unknown");
}
#[test]
fn asset_category_mapping_covers_every_kind() {
assert_eq!(
GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND.len(),
GameCreationAppAssetKind::ALL.len(),
"分类映射必须与 kind 词汇表逐项对齐"
);
for kind in GameCreationAppAssetKind::ALL {
let mapped = GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND
.iter()
.find(|(candidate, _)| candidate == kind)
.unwrap_or_else(|| panic!("kind 缺少功能分类映射:{}", kind.as_str()));
assert_eq!(
mapped.1,
game_creation_app_asset_category_for_kind(*kind),
"kind 映射不一致:{}",
kind.as_str()
);
assert!(GAME_CREATION_APP_ASSET_CATEGORIES.contains(&mapped.1));
}
assert_eq!(
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Icon),
GameCreationAppAssetCategory::UiInteraction
);
assert_eq!(
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::UiDesign),
GameCreationAppAssetCategory::UiInteraction
);
assert_eq!(
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::CharacterAnimation),
GameCreationAppAssetCategory::Character
);
assert_eq!(
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Scene),
GameCreationAppAssetCategory::Scene
);
assert_eq!(
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::SoundEffect),
GameCreationAppAssetCategory::Audio
);
assert_eq!(
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Spec),
GameCreationAppAssetCategory::Document
);
for kind in ["image", "video", "code", "publication-material"] {
assert_eq!(
game_creation_app_asset_category_for_raw_kind(kind, "test.category"),
GameCreationAppAssetCategory::Unclassified,
"{kind} 必须落待归类"
);
}
assert_eq!(
game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Font),
GameCreationAppAssetCategory::Document
);
// 严格口径:非 canonical 原值(含大小写变体与 `Object.prototype` 上的键)一律落
// 「待归类」。
for prototype_key in ["constructor", "__proto__", "toString", "valueOf"] {
assert_eq!(
game_creation_app_asset_category_for_raw_kind(prototype_key, "test.category"),
GameCreationAppAssetCategory::Unclassified
);
}
for raw in ["UI", "future-kind", "unknown-kind"] {
assert_eq!(
game_creation_app_asset_category_for_raw_kind(raw, "test.category"),
GameCreationAppAssetCategory::Unclassified,
"{raw} 是非 canonical 值,不得被归一成别的分类"
);
}
}
/// 有效分类(读显示)决策矩阵:`(kind, 落盘 category, 有效 category)`。
///
/// **这是「落盘值 / kind 派生 / 读时自愈」三个口径的唯一真源**:本表由下面的
/// `asset_effective_category_follows_the_shared_contract_matrix` 逐条断言。
/// TS 侧 `gameCreationAppAssetCategory` 是同一口径的另一份实现,由
/// `packages/shared/src/contracts/gameCreationApp.test.ts` 的同名矩阵保持同步。
const EFFECTIVE_CATEGORY_CONTRACT: [(&str, &str, &str); 8] = [
// 落盘值合法且明确 → 落盘值为准。
("character", "character", "character"),
("ui-design", "scene", "scene"),
("image", "audio", "audio"),
// 落盘 unclassified 且 kind 能派生明确分类 → 自愈成派生值。
("ui-design", "unclassified", "ui-interaction"),
("character", "unclassified", "character"),
// kind 派生结果本身就是 unclassified → 信任落盘值。
("image", "unclassified", "unclassified"),
("unknown-kind", "unclassified", "unclassified"),
("video", "unclassified", "unclassified"),
];
#[test]
fn asset_effective_category_follows_the_shared_contract_matrix() {
for (kind, persisted, expected) in EFFECTIVE_CATEGORY_CONTRACT {
let persisted = game_creation_app_asset_category_from_str(persisted)
.unwrap_or_else(|| panic!("矩阵里的落盘分类非法:{persisted}"));
let expected = game_creation_app_asset_category_from_str(expected)
.unwrap_or_else(|| panic!("矩阵里的期望分类非法:{expected}"));
assert_eq!(
game_creation_app_asset_effective_category(
GameCreationAppAssetKind::parse_with_context(kind, "test.effective-category"),
persisted,
),
expected,
"有效分类矩阵不一致:kind={kind} persisted={persisted:?}"
);
}
}
#[test]
fn asset_manifest_entry_defaults_category_and_tags_for_payloads_missing_them() {
let character = asset_entry_from_json(asset_entry_json("character"));
assert_eq!(character.category, GameCreationAppAssetCategory::Character);
assert!(character.tags.is_empty());
let icon = asset_entry_from_json(asset_entry_json("icon-spritesheet"));
assert_eq!(icon.category, GameCreationAppAssetCategory::UiInteraction);
// 缺 `category` 时按 kind 派生;非 canonical 的 kind 一律落「待归类」,
// 判定结果只出现在留痕日志里。
for raw_kind in ["future-kind", "unknown-kind"] {
let legacy = asset_entry_from_json(asset_entry_json(raw_kind));
assert_eq!(legacy.kind, GameCreationAppAssetKind::Unknown);
assert_eq!(
legacy.category,
GameCreationAppAssetCategory::Unclassified,
"{raw_kind} 不得被归一成别的分类"
);
}
}
#[test]
fn asset_manifest_entry_keeps_explicit_category_and_rejects_unknown_category() {
let mut explicit_unclassified = asset_entry_json("character");
explicit_unclassified["category"] = json!("unclassified");
assert_eq!(
asset_entry_from_json(explicit_unclassified).category,
GameCreationAppAssetCategory::Unclassified
);
let mut explicit_audio = asset_entry_json("character");
explicit_audio["category"] = json!("audio");
assert_eq!(
asset_entry_from_json(explicit_audio).category,
GameCreationAppAssetCategory::Audio
);
// 未知分类失败关闭:不能退回 `kind` 派生值。否则「读一次 + 任意一次写」会把
// 未来客户端写下的 `category` 静默改成本机派生值,等于无迁移地改落盘数据。
for kind in ["character", "image"] {
let mut unknown_category = asset_entry_json(kind);
unknown_category["category"] = json!("future-category");
let error =
serde_json::from_value::<GameCreationAppAssetManifestEntry>(unknown_category)
.expect_err("an unknown asset category must fail closed");
assert!(
error.to_string().contains("future-category"),
"unexpected error for kind={kind}: {error}"
);
}
}
/// 资产级未知字段与顶层同一取向:必须失败关闭,而不是被 serde 静默丢弃。
///
/// AGC 的写路径是「整结构体反序列化 + 整结构体重新序列化覆盖落盘」,静默丢弃等于
/// 「读一次 + 任意一次写」抹掉未来版本新增的资产字段(例如 `rotation` / `animations`)。
#[test]
fn asset_manifest_entry_rejects_unknown_asset_fields() {
let mut payload = asset_entry_json("character");
payload["rotation"] = json!(90);
let error = serde_json::from_value::<GameCreationAppAssetManifestEntry>(payload)
.expect_err("an unknown asset-level field must fail closed");
assert!(
error.to_string().contains("rotation"),
"unexpected error: {error}"
);
// 已知字段(含可选字段)必须照旧往返:拒绝未知字段不得顺手把已知字段一起拒掉。
let entry = asset_entry_from_json(asset_entry_json("character"));
let round_tripped: GameCreationAppAssetManifestEntry =
serde_json::from_value(serde_json::to_value(&entry).expect("asset entry serializes"))
.expect("known asset fields must still deserialize");
assert_eq!(round_tripped, entry);
// 顶层 manifest 也走同一条资产反序列化路径,未知资产字段不得只在裸条目上失败。
let mut manifest =
serde_json::to_value(new_game_creation_app_manifest("project-1", "像素动作原型"))
.expect("manifest should serialize");
let mut asset = asset_entry_json("character");
asset["animations"] = json!([{ "name": "idle" }]);
manifest["assets"] = json!([asset]);
let error = serde_json::from_value::<GameCreationAppManifest>(manifest)
.expect_err("an unknown asset field must fail the whole manifest read");
assert!(
error.to_string().contains("animations"),
"unexpected error: {error}"
);
}
#[test]
fn asset_manifest_entry_round_trips_category_and_tags() {
let entry = asset_entry_from_json(asset_entry_json("character"));
let payload = serde_json::to_value(&entry).expect("asset entry should serialize");
assert_eq!(payload["category"], json!("character"));
assert_eq!(payload["tags"], json!([]));
let restored: GameCreationAppAssetManifestEntry =
serde_json::from_value(payload).expect("asset entry should round trip");
assert_eq!(restored, entry);
let mut tagged = asset_entry_json("sound-effect");
tagged["category"] = json!("audio");
tagged["tags"] = json!(["战斗", "音效"]);
let entry = asset_entry_from_json(tagged);
let payload = serde_json::to_value(&entry).expect("asset entry should serialize");
assert_eq!(payload["category"], json!("audio"));
assert_eq!(payload["tags"], json!(["战斗", "音效"]));
let restored: GameCreationAppAssetManifestEntry =
serde_json::from_value(payload).expect("asset entry should round trip");
assert_eq!(restored, entry);
}
#[test]
fn asset_tags_normalization_trims_drops_empty_and_dedupes() {
let tags = vec![
" 主角 ".to_string(),
String::new(),
"主角".to_string(),
" ".to_string(),
"战斗".to_string(),
"战斗".to_string(),
];
assert_eq!(
normalize_game_creation_app_asset_tags(&tags),
vec!["主角".to_string(), "战斗".to_string()]
);
assert!(normalize_game_creation_app_asset_tags(&[]).is_empty());
}
#[test]
fn iteration_versions_require_an_append_ordered_parent_graph() {
let root = GameIterationVersion {
version_id: "version-root".to_string(),
parent_version_id: None,
project_revision: 4,
resource_bindings: vec![GameIterationVersionResourceBinding {
slot_id: "player".to_string(),
resource_id: "asset-player".to_string(),
}],
created_reason: GameIterationVersionCreatedReason::Initial,
created_at: 100,
edit_prompt: None,
};
let child = GameIterationVersion {
version_id: "version-child".to_string(),
parent_version_id: Some(root.version_id.clone()),
project_revision: 5,
resource_bindings: Vec::new(),
created_reason: GameIterationVersionCreatedReason::AgentRevision,
created_at: 101,
edit_prompt: Some("继续优化这一版".to_string()),
};
validate_game_iteration_versions(&[root.clone(), child.clone()])
.expect("valid version graph");
let mut invalid_child = child.clone();
invalid_child.version_id = root.version_id.clone();
assert!(
validate_game_iteration_versions(&[root.clone(), invalid_child])
.expect_err("reject duplicate version id")
.contains("版本 ID 重复")
);
let mut invalid_child = child.clone();
invalid_child.parent_version_id = Some("missing".to_string());
assert!(
validate_game_iteration_versions(&[root.clone(), invalid_child])
.expect_err("reject missing parent")
.contains("父版本必须先于子版本存在")
);
let mut invalid_child = child.clone();
invalid_child.project_revision = root.project_revision;
assert!(
validate_game_iteration_versions(&[root.clone(), invalid_child])
.expect_err("reject non-increasing revision")
.contains("projectRevision 必须大于父版本")
);
let mut invalid_child = child.clone();
invalid_child.created_at = root.created_at - 1;
assert!(
validate_game_iteration_versions(&[root.clone(), invalid_child])
.expect_err("reject time before parent")
.contains("createdAt 不能早于父版本")
);
let mut invalid_child = child.clone();
invalid_child.project_revision = GAME_ITERATION_VERSION_MAX_SAFE_INTEGER + 1;
assert!(
validate_game_iteration_versions(&[root.clone(), invalid_child])
.expect_err("reject unsafe project revision")
.contains("超出 JavaScript 安全整数范围")
);
let mut invalid_child = child;
invalid_child.resource_bindings = vec![
GameIterationVersionResourceBinding {
slot_id: "player".to_string(),
resource_id: "asset-player".to_string(),
},
GameIterationVersionResourceBinding {
slot_id: "player".to_string(),
resource_id: "asset-player-next".to_string(),
},
];
assert!(
validate_game_iteration_versions(&[root, invalid_child])
.expect_err("reject duplicate slot")
.contains("资源槽位重复")
);
}
#[test]
fn resource_canvas_layout_uses_frozen_json_contract() {
let layout = ProjectResourceCanvasLayout {
schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(),
project_id: "project-layout-1".to_string(),
mode: ProjectResourceCanvasLayoutMode::Dependency,
revision: 1,
positions: vec![ProjectResourceCanvasPosition {
resource_id: "asset-1".to_string(),
section: ProjectResourceCanvasSection::Scene,
x: 10,
y: 20,
manually_placed: true,
}],
updated_at: 123,
};
assert_eq!(
serde_json::to_value(layout).expect("resource layout should serialize"),
json!({
"schemaVersion": "game-creator-resource-layout.v1",
"projectId": "project-layout-1",
"mode": "dependency",
"revision": 1,
"positions": [
{
"resourceId": "asset-1",
"section": "scene",
"x": 10,
"y": 20,
"manuallyPlaced": true
}
],
"updatedAt": 123
})
);
}
#[test]
fn resource_canvas_partition_axis_keeps_asset_categories_then_version() {
assert_eq!(
PROJECT_RESOURCE_CANVAS_SECTIONS
.map(|section| serde_json::to_value(section).expect("section should serialize")),
[
json!("ui-interaction"),
json!("character"),
json!("scene"),
json!("audio"),
json!("document"),
json!("unclassified"),
json!("version"),
]
);
}
#[test]
fn resource_canvas_layout_reads_legacy_section_values() {
for (stored, expected) in [
("code", ProjectResourceCanvasSection::Code),
("art", ProjectResourceCanvasSection::Art),
("document", ProjectResourceCanvasSection::Document),
("audio", ProjectResourceCanvasSection::Audio),
("version", ProjectResourceCanvasSection::Version),
] {
let payload = json!({
"schemaVersion": GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
"projectId": "project-layout-legacy",
"mode": "type",
"revision": 4,
"positions": [{
"resourceId": "asset-legacy",
"section": stored,
"x": -24,
"y": 96,
"manuallyPlaced": true
}],
"updatedAt": 9
});
let layout: ProjectResourceCanvasLayout =
serde_json::from_value(payload).expect("legacy section must stay readable");
assert_eq!(layout.positions[0].section, expected);
assert_eq!(layout.positions[0].x, -24);
assert!(layout.positions[0].manually_placed);
assert_eq!(
serde_json::to_value(layout.positions[0].section)
.expect("legacy section should serialize"),
json!(stored)
);
}
}
#[test]
fn resource_canvas_layout_rejects_unknown_section_values() {
let payload = json!({
"schemaVersion": GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
"projectId": "project-layout-unknown",
"mode": "type",
"revision": 1,
"positions": [{
"resourceId": "asset-unknown",
"section": "not-a-section",
"x": 0,
"y": 0,
"manuallyPlaced": false
}],
"updatedAt": 1
});
assert!(serde_json::from_value::<ProjectResourceCanvasLayout>(payload).is_err());
}
#[test]
fn resource_canvas_layout_revision_rejects_json_outside_js_safe_integer_range() {
let layout = ProjectResourceCanvasLayout {
schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(),
project_id: "project-layout-safe-revision".to_string(),
mode: ProjectResourceCanvasLayoutMode::Dependency,
revision: GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION,
positions: vec![],
updated_at: 123,
};
let serialized = serde_json::to_vec(&layout).expect("safe revision should serialize");
let decoded: ProjectResourceCanvasLayout =
serde_json::from_slice(&serialized).expect("safe revision should deserialize");
assert_eq!(
decoded.revision,
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION
);
let unsafe_layout = ProjectResourceCanvasLayout {
revision: GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1,
..layout
};
assert!(serde_json::to_value(unsafe_layout).is_err());
let unsafe_payload = json!({
"schemaVersion": "game-creator-resource-layout.v1",
"projectId": "project-layout-safe-revision",
"mode": "dependency",
"revision": GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1,
"positions": [],
"updatedAt": 123
});
assert!(serde_json::from_value::<ProjectResourceCanvasLayout>(unsafe_payload).is_err());
}
}