Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs
T
kdletters e8403e547b
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m15s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 4m33s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m33s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m29s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m19s
Project CI / AI game creator shell Rust crates (push) Successful in 1m53s
Project CI / Frontend tests (push) Successful in 4m10s
Project CI / Repository checks (push) Successful in 3m19s
Project CI / Native shell tests (push) Successful in 6m39s
Project CI / Backend tests (push) Successful in 7m27s
Project CI / AI game creator shell web tests (push) Successful in 3m6s
补齐游戏从策划到成品的完整生产流程
新增 AGC 完整游戏生产 workflow Skill,定义策划、资源、实现、构建、试玩和交付阶段。

将真实美术生成、处理、登记和接入从可选提示改为新游戏交付链路要求。

同步 Skill Pack 清单、系统提示、测试契约和实施计划文档。
2026-09-14 18:43:12 +08:00

395 lines
15 KiB
Rust

use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::borrow::Cow;
use std::collections::BTreeSet;
use std::path::{Component, Path};
const AGC_SKILL_PACK_MANIFEST: &[u8] = include_bytes!("../../resources/agc-skills/manifest.json");
const AGC_SKILL_PACK_SCHEMA_VERSION: &str = "agc-skill-pack.v1";
pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 6] = [
"agc-browser-playtest",
"agc-client-projection",
"agc-game-production-workflow",
"agc-project-structure",
"agc-web-game-development",
"taonier-art-assets",
];
const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 18] = [
(
"agc-browser-playtest/SKILL.md",
include_bytes!("../../resources/agc-skills/agc-browser-playtest/SKILL.md"),
),
(
"agc-browser-playtest/agents/openai.yaml",
include_bytes!("../../resources/agc-skills/agc-browser-playtest/agents/openai.yaml"),
),
(
"agc-browser-playtest/references/browser-evidence-contract.md",
include_bytes!(
"../../resources/agc-skills/agc-browser-playtest/references/browser-evidence-contract.md"
),
),
(
"agc-client-projection/SKILL.md",
include_bytes!("../../resources/agc-skills/agc-client-projection/SKILL.md"),
),
(
"agc-client-projection/agents/openai.yaml",
include_bytes!("../../resources/agc-skills/agc-client-projection/agents/openai.yaml"),
),
(
"agc-client-projection/references/projection-contract.md",
include_bytes!(
"../../resources/agc-skills/agc-client-projection/references/projection-contract.md"
),
),
(
"agc-game-production-workflow/SKILL.md",
include_bytes!("../../resources/agc-skills/agc-game-production-workflow/SKILL.md"),
),
(
"agc-game-production-workflow/agents/openai.yaml",
include_bytes!("../../resources/agc-skills/agc-game-production-workflow/agents/openai.yaml"),
),
(
"agc-game-production-workflow/references/workflow-contract.md",
include_bytes!(
"../../resources/agc-skills/agc-game-production-workflow/references/workflow-contract.md"
),
),
(
"agc-project-structure/SKILL.md",
include_bytes!("../../resources/agc-skills/agc-project-structure/SKILL.md"),
),
(
"agc-project-structure/agents/openai.yaml",
include_bytes!("../../resources/agc-skills/agc-project-structure/agents/openai.yaml"),
),
(
"agc-project-structure/references/structure-contract.md",
include_bytes!(
"../../resources/agc-skills/agc-project-structure/references/structure-contract.md"
),
),
(
"agc-web-game-development/SKILL.md",
include_bytes!("../../resources/agc-skills/agc-web-game-development/SKILL.md"),
),
(
"agc-web-game-development/agents/openai.yaml",
include_bytes!("../../resources/agc-skills/agc-web-game-development/agents/openai.yaml"),
),
(
"agc-web-game-development/references/game-quality-checklist.md",
include_bytes!(
"../../resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md"
),
),
(
"taonier-art-assets/SKILL.md",
include_bytes!("../../resources/agc-skills/taonier-art-assets/SKILL.md"),
),
(
"taonier-art-assets/agents/openai.yaml",
include_bytes!("../../resources/agc-skills/taonier-art-assets/agents/openai.yaml"),
),
(
"taonier-art-assets/references/platform-art-contract.md",
include_bytes!(
"../../resources/agc-skills/taonier-art-assets/references/platform-art-contract.md"
),
),
];
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct AgcSkillPackManifest {
schema_version: String,
version: String,
skills: Vec<AgcSkillManifestEntry>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct AgcSkillManifestEntry {
name: String,
purpose: String,
triggers: Vec<String>,
required_tools: Vec<String>,
files: Vec<String>,
sha256: String,
}
fn is_safe_skill_relative_path(value: &str) -> bool {
let path = Path::new(value);
!value.is_empty()
&& !value.contains('\\')
&& !value.contains(':')
&& !path.is_absolute()
&& path
.components()
.all(|component| matches!(component, Component::Normal(part) if !part.is_empty()))
}
fn bundled_skill_file(path: &str) -> Option<&'static [u8]> {
AGC_SKILL_PACK_FILES
.iter()
.find_map(|(candidate, bytes)| (*candidate == path).then_some(*bytes))
}
fn canonical_skill_text_bytes<'a>(path: &str, bytes: &'a [u8]) -> Result<Cow<'a, [u8]>, String> {
let text = std::str::from_utf8(bytes)
.map_err(|_| format!("内置 AGC Skill 审核文件 {path} 不是 UTF-8 文本"))?;
if !text.contains("\r\n") {
return Ok(Cow::Borrowed(bytes));
}
Ok(Cow::Owned(text.replace("\r\n", "\n").into_bytes()))
}
fn update_skill_content_digest(
digest: &mut Sha256,
relative: &str,
bundled_path: &str,
bytes: &[u8],
) -> Result<(), String> {
let canonical_bytes = canonical_skill_text_bytes(bundled_path, bytes)?;
digest.update(relative.as_bytes());
digest.update([0]);
digest.update(canonical_bytes.as_ref());
digest.update([0]);
Ok(())
}
fn skill_content_fingerprint(entry: &AgcSkillManifestEntry) -> Result<String, String> {
let mut files = entry.files.clone();
files.sort();
let mut digest = Sha256::new();
for relative in files {
if !is_safe_skill_relative_path(&relative) {
return Err(format!("AGC Skill {} 包含不安全路径", entry.name));
}
let bundled_path = format!("{}/{}", entry.name, relative.replace('\\', "/"));
let bytes = bundled_skill_file(&bundled_path)
.ok_or_else(|| format!("AGC Skill {} 缺少审核文件 {relative}", entry.name))?;
update_skill_content_digest(&mut digest, &relative, &bundled_path, bytes)?;
}
Ok(format!("{:x}", digest.finalize()))
}
fn validated_skill_pack_manifest() -> Result<AgcSkillPackManifest, String> {
let manifest = serde_json::from_slice::<AgcSkillPackManifest>(AGC_SKILL_PACK_MANIFEST)
.map_err(|error| format!("解析内置 AGC Skill 清单失败:{error}"))?;
if manifest.schema_version != AGC_SKILL_PACK_SCHEMA_VERSION {
return Err("内置 AGC Skill 清单版本不受支持".to_string());
}
if manifest.version.trim().is_empty() {
return Err("内置 AGC Skill 清单缺少版本".to_string());
}
let names = manifest
.skills
.iter()
.map(|entry| entry.name.as_str())
.collect::<BTreeSet<_>>();
if names != AGC_SKILL_PACK_EXPECTED_NAMES.into_iter().collect() {
return Err("内置 AGC Skill 清单不等于审核白名单".to_string());
}
let mut declared_files = BTreeSet::new();
for entry in &manifest.skills {
if entry.purpose.trim().is_empty()
|| entry.triggers.is_empty()
|| entry
.triggers
.iter()
.any(|trigger| trigger.trim().is_empty())
|| !entry.files.iter().any(|path| path == "SKILL.md")
|| entry.files.is_empty()
{
return Err(format!("内置 AGC Skill {} 元数据不完整", entry.name));
}
if entry.files.iter().collect::<BTreeSet<_>>().len() != entry.files.len() {
return Err(format!("内置 AGC Skill {} 文件声明重复", entry.name));
}
let actual = skill_content_fingerprint(entry)?;
if actual != entry.sha256 {
return Err(format!("内置 AGC Skill {} 内容指纹不匹配", entry.name));
}
for relative in &entry.files {
declared_files.insert(format!("{}/{}", entry.name, relative.replace('\\', "/")));
}
}
let bundled_files = AGC_SKILL_PACK_FILES
.iter()
.map(|(path, _)| (*path).to_string())
.collect::<BTreeSet<_>>();
if declared_files != bundled_files {
return Err("内置 AGC Skill 文件集合与审核清单不一致".to_string());
}
Ok(manifest)
}
pub(crate) fn agc_skill_pack_fingerprint() -> Result<String, String> {
validated_skill_pack_manifest()?;
let canonical_manifest = canonical_skill_text_bytes("manifest.json", AGC_SKILL_PACK_MANIFEST)?;
Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref())))
}
pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> {
let manifest = validated_skill_pack_manifest()?;
let mut lines = vec![format!(
"审核 AGC Skill 索引({};完整正文由 Codex 命中后按需读取):",
manifest.version
)];
for entry in manifest.skills {
lines.push(format!(
"- {} | 用途:{} | 触发:{} | 工具:{} | sha256:{}",
entry.name,
entry.purpose,
entry.triggers.join("、"),
if entry.required_tools.is_empty() {
"无".to_string()
} else {
entry.required_tools.join("、")
},
&entry.sha256[..12],
));
}
Ok(lines.join("\n"))
}
pub(crate) fn read_agc_skill_resource(resource: &str) -> Result<String, String> {
let manifest = validated_skill_pack_manifest()?;
let normalized = resource.trim().trim_start_matches('/').replace('\\', "/");
let (skill_name, relative) = normalized
.split_once('/')
.ok_or_else(|| "Skill 资源路径必须是 skill/file".to_string())?;
let entry = manifest
.skills
.iter()
.find(|entry| entry.name == skill_name)
.ok_or_else(|| "未登记的 AGC Skill 资源".to_string())?;
if !entry.files.iter().any(|file| file == relative) || !is_safe_skill_relative_path(relative) {
return Err("未登记或不安全的 AGC Skill 资源".to_string());
}
let bundled_path = format!("{skill_name}/{relative}");
let bytes =
bundled_skill_file(&bundled_path).ok_or_else(|| "AGC Skill 资源不存在".to_string())?;
let canonical = canonical_skill_text_bytes(&bundled_path, bytes)?;
String::from_utf8(canonical.into_owned()).map_err(|_| "AGC Skill 资源不是 UTF-8".to_string())
}
pub(crate) fn install_agc_skill_pack(isolated_os_home: &Path) -> Result<String, String> {
let manifest = validated_skill_pack_manifest()?;
let skills_root = isolated_os_home.join(".agents").join("skills");
std::fs::create_dir_all(&skills_root)
.map_err(|error| format!("创建隔离 AGC Skill 目录失败:{error}"))?;
for entry in &manifest.skills {
for relative in &entry.files {
let bundled_path = format!("{}/{}", entry.name, relative.replace('\\', "/"));
let bytes = bundled_skill_file(&bundled_path)
.ok_or_else(|| format!("内置 AGC Skill 缺少审核文件 {bundled_path}"))?;
let canonical_bytes = canonical_skill_text_bytes(&bundled_path, bytes)?;
let target = skills_root.join(&entry.name).join(relative);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("创建隔离 AGC Skill 子目录失败:{error}"))?;
}
std::fs::write(&target, canonical_bytes.as_ref())
.map_err(|error| format!("安装隔离 AGC Skill 文件失败:{error}"))?;
}
}
agc_skill_pack_fingerprint()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundled_skill_pack_is_exactly_the_six_reviewed_skills() {
let manifest = validated_skill_pack_manifest().expect("validated manifest");
assert_eq!(manifest.schema_version, "agc-skill-pack.v1");
assert_eq!(manifest.skills.len(), 6);
assert!(manifest.skills.iter().all(|entry| entry.sha256.len() == 64));
let serialized = serde_json::to_string(
&manifest
.skills
.iter()
.map(|entry| &entry.name)
.collect::<Vec<_>>(),
)
.expect("serialize names");
for denied in [
"spacetimedb",
"wechatpay",
"genarrative-play-type-integration",
] {
assert!(!serialized.contains(denied));
}
}
#[test]
fn skill_content_digest_is_stable_across_lf_and_crlf() {
fn digest(bytes: &[u8]) -> String {
let mut digest = Sha256::new();
update_skill_content_digest(
&mut digest,
"agents/openai.yaml",
"agc-project-structure/agents/openai.yaml",
bytes,
)
.expect("hash reviewed skill text");
format!("{:x}", digest.finalize())
}
assert_eq!(
digest(b"interface:\n display_name: AGC\n short_description: test\n"),
digest(b"interface:\r\n display_name: AGC\n short_description: test\r\n")
);
}
#[test]
fn skill_pack_installs_under_isolated_home_without_full_body_in_index() {
let home = tempfile::tempdir().expect("temporary home");
let fingerprint = install_agc_skill_pack(home.path()).expect("install skill pack");
assert_eq!(fingerprint.len(), 64);
assert!(home
.path()
.join(".agents/skills/taonier-art-assets/SKILL.md")
.is_file());
assert!(home
.path()
.join(".agents/skills/agc-project-structure/references/structure-contract.md")
.is_file());
let installed_agent_metadata = std::fs::read(
home.path()
.join(".agents/skills/agc-project-structure/agents/openai.yaml"),
)
.expect("read installed agent metadata");
assert!(!installed_agent_metadata
.windows(2)
.any(|pair| pair == b"\r\n"));
let index = render_agc_skill_pack_index().expect("render index");
assert!(index.contains("taonier-art-assets"));
assert!(index.contains("agc_tools.taonier_prepare_game_art"));
assert!(index.contains("agc_tools.agc_list_registered_assets"));
assert!(index.contains("agc_tools.agc_list_project_files"));
assert!(index.contains("agc_tools.agc_list_account_assets"));
assert!(index.contains("agc_tools.agc_import_account_assets"));
assert!(index.contains("agc_tools.agc_create_or_derive_resource"));
assert!(!index.contains("Use real platform assets only"));
assert!(!index.contains("postprocess-failed-source-preserved"));
}
#[test]
fn skill_relative_paths_reject_escape_and_absolute_forms() {
assert!(is_safe_skill_relative_path("references/contract.md"));
assert!(!is_safe_skill_relative_path("../SKILL.md"));
assert!(!is_safe_skill_relative_path("/tmp/SKILL.md"));
assert!(!is_safe_skill_relative_path(r"C:\\temp\\SKILL.md"));
assert!(!is_safe_skill_relative_path("C:/temp/SKILL.md"));
assert!(!is_safe_skill_relative_path(r"\\server\share\SKILL.md"));
assert!(!is_safe_skill_relative_path(r"references\contract.md"));
}
}