1498247e9f
新增 npm + Vite + Phaser 4.2.1 游戏工程脚手架 允许 DirectProject 在项目边界内安装依赖并执行构建 让预览、静态检查、投影和试玩导出统一使用 dist 产物 补充 Phaser Skill、技术方案、项目记忆和定向测试
349 lines
13 KiB
Rust
349 lines
13 KiB
Rust
use super::*;
|
||
|
||
pub(crate) fn export_local_project_package_at(
|
||
root: &Path,
|
||
) -> Result<LocalProjectExportPackageResult, String> {
|
||
validate_project_root(root)?;
|
||
super::verification::validate_project_game_entry(root)?;
|
||
ensure_project_export_package_dir(root, "exports")?;
|
||
let readme_path = resolve_local_project_path(root, "exports/README.md")?;
|
||
if !readme_path.is_file() {
|
||
return Err("导出试玩包前需要先生成 exports/README.md".to_string());
|
||
}
|
||
let readme_metadata = checked_export_package_metadata(&readme_path, "exports/README.md")?;
|
||
if !readme_metadata.is_file() {
|
||
return Err("导出试玩包前需要先生成 exports/README.md".to_string());
|
||
}
|
||
|
||
let files = collect_project_export_package_files(root)?;
|
||
let total_bytes = files.iter().map(|(_, _, size)| *size).sum::<u64>();
|
||
if files.len() > MAX_PROJECT_EXPORT_PACKAGE_FILES {
|
||
return Err(format!(
|
||
"试玩包文件数量超过上限:{} > {}",
|
||
files.len(),
|
||
MAX_PROJECT_EXPORT_PACKAGE_FILES
|
||
));
|
||
}
|
||
if total_bytes > MAX_PROJECT_EXPORT_PACKAGE_BYTES {
|
||
return Err(format!(
|
||
"试玩包文件总大小超过上限:{}B > {}B",
|
||
total_bytes, MAX_PROJECT_EXPORT_PACKAGE_BYTES
|
||
));
|
||
}
|
||
let package_relative_path = next_project_export_package_relative_path(root)?;
|
||
let package_path = resolve_local_project_path(root, &package_relative_path)?;
|
||
if let Some(parent) = package_path.parent() {
|
||
ensure_game_creator_private_directory_tree(parent, "导出目录")?;
|
||
prepare_game_creator_private_path_for_read(parent, true, "导出目录")?;
|
||
}
|
||
prepare_game_creator_private_path_for_read(&package_path, false, "试玩包")?;
|
||
|
||
let mut options = fs::OpenOptions::new();
|
||
options.write(true).create_new(true);
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::OpenOptionsExt;
|
||
options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
|
||
}
|
||
let file = options
|
||
.open(&package_path)
|
||
.map_err(|error| format!("创建试玩包失败:{}: {error}", package_path.display()))?;
|
||
if let Err(error) = harden_new_game_creator_private_path(&package_path, false, "试玩包") {
|
||
drop(file);
|
||
let _ = fs::remove_file(&package_path);
|
||
return Err(error);
|
||
}
|
||
let mut writer = zip::ZipWriter::new(file);
|
||
let options = zip::write::SimpleFileOptions::default()
|
||
.compression_method(zip::CompressionMethod::Deflated);
|
||
for (relative_path, absolute_path, _) in &files {
|
||
writer
|
||
.start_file(relative_path, options)
|
||
.map_err(|error| format!("写入试玩包条目失败:{relative_path}: {error}"))?;
|
||
prepare_game_creator_private_path_for_read(absolute_path, false, "导出文件")?;
|
||
let bytes = fs::read(absolute_path)
|
||
.map_err(|error| format!("读取导出文件失败:{}: {error}", absolute_path.display()))?;
|
||
writer
|
||
.write_all(&bytes)
|
||
.map_err(|error| format!("写入试玩包文件失败:{relative_path}: {error}"))?;
|
||
}
|
||
writer
|
||
.finish()
|
||
.map_err(|error| format!("完成试玩包失败:{}: {error}", package_path.display()))?;
|
||
prepare_game_creator_private_path_for_read(&package_path, false, "试玩包")?;
|
||
|
||
let updated_at = unix_timestamp();
|
||
let log_path = root.join(".agent/logs/command.log");
|
||
let output = format!(
|
||
"导出试玩包:{},{} 个文件,{}B",
|
||
package_relative_path,
|
||
files.len(),
|
||
total_bytes
|
||
);
|
||
let line = format!("{updated_at} project.export_package: {output}\n");
|
||
append_game_creator_private_file(&log_path, line.as_bytes(), "命令日志")?;
|
||
record_command_run(
|
||
root,
|
||
GameCreationAppCommandRunState {
|
||
command_id: "project.export_package".to_string(),
|
||
status: GameCreationAppCommandRunStatus::Completed,
|
||
output,
|
||
log_path: log_path.to_string_lossy().into_owned(),
|
||
updated_at,
|
||
},
|
||
)?;
|
||
append_agent_db_record(
|
||
root,
|
||
serde_json::json!({
|
||
"recordType": "project.export_package",
|
||
"packagePath": package_relative_path,
|
||
"fileCount": files.len(),
|
||
"totalBytes": total_bytes,
|
||
}),
|
||
)?;
|
||
|
||
Ok(LocalProjectExportPackageResult {
|
||
package_path: package_path.to_string_lossy().into_owned(),
|
||
package_relative_path,
|
||
file_count: files.len(),
|
||
total_bytes,
|
||
})
|
||
}
|
||
|
||
pub(crate) fn next_project_export_package_relative_path(root: &Path) -> Result<String, String> {
|
||
let seed = unix_millis();
|
||
for suffix in 0..1000 {
|
||
let file_name = if suffix == 0 {
|
||
format!("playtest-package-{seed}.zip")
|
||
} else {
|
||
format!("playtest-package-{seed}-{suffix}.zip")
|
||
};
|
||
let relative_path = format!("exports/{file_name}");
|
||
let package_path = resolve_local_project_path(root, &relative_path)?;
|
||
if !package_path.exists() {
|
||
return Ok(relative_path);
|
||
}
|
||
}
|
||
Err("无法生成唯一试玩包文件名".to_string())
|
||
}
|
||
|
||
pub(crate) fn list_local_project_export_packages_at(
|
||
root: &Path,
|
||
) -> Result<LocalProjectExportPackagesResult, String> {
|
||
validate_project_root(root)?;
|
||
let export_dir = resolve_local_project_path(root, "exports")?;
|
||
if !export_dir.exists() {
|
||
return Ok(LocalProjectExportPackagesResult {
|
||
project_path: root.to_string_lossy().into_owned(),
|
||
packages: Vec::new(),
|
||
});
|
||
}
|
||
let export_dir_metadata = checked_export_package_metadata(&export_dir, "exports")?;
|
||
if !export_dir_metadata.is_dir() {
|
||
return Err("exports 必须是目录".to_string());
|
||
}
|
||
|
||
let mut packages = Vec::new();
|
||
for entry in fs::read_dir(&export_dir)
|
||
.map_err(|error| format!("读取试玩包目录失败:{}: {error}", export_dir.display()))?
|
||
{
|
||
let entry = entry
|
||
.map_err(|error| format!("读取试玩包文件失败:{}: {error}", export_dir.display()))?;
|
||
let file_type = entry.file_type().map_err(|error| {
|
||
format!(
|
||
"读取试玩包文件类型失败:{}: {error}",
|
||
entry.path().display()
|
||
)
|
||
})?;
|
||
if file_type.is_symlink() || !file_type.is_file() {
|
||
continue;
|
||
}
|
||
let file_name = match entry.file_name().to_str() {
|
||
Some(value) => value.to_string(),
|
||
None => continue,
|
||
};
|
||
if !file_name.starts_with("playtest-package-") || !file_name.ends_with(".zip") {
|
||
continue;
|
||
}
|
||
let package_relative_path =
|
||
normalize_export_package_entry_path(&format!("exports/{file_name}"))?;
|
||
let metadata = entry.metadata().map_err(|error| {
|
||
format!("读取试玩包元数据失败:{}: {error}", entry.path().display())
|
||
})?;
|
||
let modified_at = metadata
|
||
.modified()
|
||
.ok()
|
||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
|
||
.unwrap_or(0);
|
||
packages.push(LocalProjectExportPackageSummary {
|
||
package_path: entry.path().to_string_lossy().into_owned(),
|
||
package_relative_path,
|
||
total_bytes: metadata.len(),
|
||
modified_at,
|
||
});
|
||
}
|
||
packages.sort_by(|left, right| {
|
||
(right.modified_at, &right.package_relative_path)
|
||
.cmp(&(left.modified_at, &left.package_relative_path))
|
||
});
|
||
|
||
Ok(LocalProjectExportPackagesResult {
|
||
project_path: root.to_string_lossy().into_owned(),
|
||
packages,
|
||
})
|
||
}
|
||
|
||
pub(crate) fn collect_project_export_package_files(
|
||
root: &Path,
|
||
) -> Result<Vec<(String, PathBuf, u64)>, String> {
|
||
let mut files = Vec::new();
|
||
let game_root = crate::preview::project_game_root(root);
|
||
let built = game_root == root.join("dist") || game_root == root.join("game/dist");
|
||
if built {
|
||
let relative = relative_project_path(root, &game_root)?;
|
||
collect_project_export_package_dir_files(root, &relative, &mut files)?;
|
||
for (name, _, _) in &mut files {
|
||
*name = format!(
|
||
"game/{}",
|
||
name.strip_prefix(&format!("{relative}/"))
|
||
.ok_or("构建产物路径非法")?
|
||
);
|
||
}
|
||
} else {
|
||
collect_project_export_package_dir_files(root, "game", &mut files)?;
|
||
}
|
||
if !built && resolve_local_project_path(root, "assets")?.exists() {
|
||
collect_project_export_package_dir_files(root, "assets", &mut files)?;
|
||
}
|
||
let readme_path = resolve_local_project_path(root, "exports/README.md")?;
|
||
let readme_metadata = checked_export_package_metadata(&readme_path, "exports/README.md")?;
|
||
if !readme_metadata.is_file() {
|
||
return Err("导出试玩包前需要先生成 exports/README.md".to_string());
|
||
}
|
||
let readme_size = readme_metadata.len();
|
||
files.push(("exports/README.md".to_string(), readme_path, readme_size));
|
||
files.sort_by(|left, right| left.0.cmp(&right.0));
|
||
Ok(files)
|
||
}
|
||
|
||
pub(crate) fn ensure_project_export_package_dir(
|
||
root: &Path,
|
||
relative_dir: &str,
|
||
) -> Result<(), String> {
|
||
let dir = resolve_local_project_path(root, relative_dir)?;
|
||
let metadata = checked_export_package_metadata(&dir, relative_dir)?;
|
||
if !metadata.is_dir() {
|
||
return Err(format!("{relative_dir} 必须是目录"));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn collect_project_export_package_dir_files(
|
||
root: &Path,
|
||
relative_dir: &str,
|
||
files: &mut Vec<(String, PathBuf, u64)>,
|
||
) -> Result<(), String> {
|
||
let dir = resolve_local_project_path(root, relative_dir)?;
|
||
if !dir.exists() {
|
||
return Ok(());
|
||
}
|
||
let dir_metadata = checked_export_package_metadata(&dir, relative_dir)?;
|
||
if !dir_metadata.is_dir() {
|
||
return Err(format!("{relative_dir} 必须是目录"));
|
||
}
|
||
let mut dirs = vec![dir];
|
||
while let Some(current_dir) = dirs.pop() {
|
||
for entry in fs::read_dir(¤t_dir)
|
||
.map_err(|error| format!("读取导出目录失败:{}: {error}", current_dir.display()))?
|
||
{
|
||
let entry = entry
|
||
.map_err(|error| format!("读取导出文件失败:{}: {error}", current_dir.display()))?;
|
||
let file_type = entry.file_type().map_err(|error| {
|
||
format!("读取导出文件类型失败:{}: {error}", entry.path().display())
|
||
})?;
|
||
let relative_path =
|
||
normalize_export_package_entry_path(&relative_project_path(root, &entry.path())?)?;
|
||
if file_type.is_symlink() {
|
||
return Err(format!("试玩包不能包含符号链接:{relative_path}"));
|
||
}
|
||
if file_type.is_dir() {
|
||
dirs.push(entry.path());
|
||
} else if file_type.is_file() {
|
||
let size = entry
|
||
.metadata()
|
||
.map_err(|error| {
|
||
format!(
|
||
"读取导出文件元数据失败:{}: {error}",
|
||
entry.path().display()
|
||
)
|
||
})?
|
||
.len();
|
||
files.push((relative_path, entry.path(), size));
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn checked_export_package_metadata(
|
||
path: &Path,
|
||
relative_path: &str,
|
||
) -> Result<fs::Metadata, String> {
|
||
let metadata = fs::symlink_metadata(path)
|
||
.map_err(|error| format!("读取导出文件类型失败:{}: {error}", path.display()))?;
|
||
if metadata.file_type().is_symlink() {
|
||
return Err(format!(
|
||
"试玩包不能包含符号链接:{}",
|
||
normalize_export_package_entry_path(relative_path)?
|
||
));
|
||
}
|
||
Ok(metadata)
|
||
}
|
||
|
||
pub(crate) fn normalize_export_package_entry_path(relative_path: &str) -> Result<String, String> {
|
||
if relative_path.chars().any(char::is_control) {
|
||
return Err("试玩包条目路径不能包含控制字符".to_string());
|
||
}
|
||
normalize_relative_path(relative_path)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod npm_export_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn npm_package_contains_only_dist_and_publish_readme() {
|
||
let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp");
|
||
fs::create_dir_all(&base).unwrap();
|
||
let root = tempfile::tempdir_in(base).unwrap();
|
||
for directory in ["dist/assets", "assets", "exports", "node_modules", "game"] {
|
||
fs::create_dir_all(root.path().join(directory)).unwrap();
|
||
}
|
||
for file in [
|
||
"package.json",
|
||
"dist/index.html",
|
||
"dist/assets/main.js",
|
||
"assets/hero.png",
|
||
"exports/README.md",
|
||
"node_modules/private.js",
|
||
"game/source.js",
|
||
] {
|
||
fs::write(root.path().join(file), "test").unwrap();
|
||
}
|
||
let files = collect_project_export_package_files(root.path()).unwrap();
|
||
let names = files
|
||
.iter()
|
||
.map(|(name, _, _)| name.as_str())
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(
|
||
names,
|
||
vec![
|
||
"exports/README.md",
|
||
"game/assets/main.js",
|
||
"game/index.html"
|
||
]
|
||
);
|
||
}
|
||
}
|