54d0fb75ea
新增 GDExtension 自动引导、GDScript 执行和实例隔离缓存 接入 AGC 插件开关、Runner、Agent 工具与权限审计 完善执行回执确认、不确定状态阻断及卸载恢复 补齐 Windows 分发资源、定向测试与实机验收文档
2683 lines
105 KiB
Rust
2683 lines
105 KiB
Rust
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());
|
||
}
|
||
}
|