diff --git a/src/boss_engine/copy.rs b/src/boss_engine/copy.rs new file mode 100644 index 0000000..34cf830 --- /dev/null +++ b/src/boss_engine/copy.rs @@ -0,0 +1,35 @@ +use crate::error::Result; +use std::path::Path; + +/// 递归复制项目目录,排除指定的目录名和文件名。用于 fork 的项目复制。 +pub fn copy_dir_filtered( + src: &Path, + dst: &Path, + exclude_dirs: &[&str], + exclude_files: &[&str], +) -> Result<()> { + std::fs::create_dir_all(dst)?; + + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let entry_path = entry.path(); + let file_name = entry.file_name(); + let name_str = file_name.to_string_lossy(); + + if entry_path.is_dir() { + if exclude_dirs.contains(&name_str.as_ref()) { + continue; + } + let dest_path = dst.join(&file_name); + copy_dir_filtered(&entry_path, &dest_path, exclude_dirs, exclude_files)?; + } else if entry_path.is_file() { + if exclude_files.contains(&name_str.as_ref()) { + continue; + } + let dest_path = dst.join(&file_name); + std::fs::copy(&entry_path, &dest_path)?; + } + } + + Ok(()) +} diff --git a/src/boss_engine/mod.rs b/src/boss_engine/mod.rs new file mode 100644 index 0000000..7c79a9e --- /dev/null +++ b/src/boss_engine/mod.rs @@ -0,0 +1,114 @@ +pub mod copy; + +use std::path::{Path, PathBuf}; + +use crate::error::{CliError, Result}; + +/// 发现项目布局: BP/RP 目录 + scripts package 目录。 +/// 逻辑与 AbyssBossEngine tests/conftest.py 的自动发现一致。 +pub fn discover_layout(project_dir: &Path) -> Result { + let behavior_pack = find_single_dir(project_dir, "behavior_pack_")?; + let resource_pack = find_single_dir(project_dir, "resource_pack_")?; + let scripts_package = find_scripts_package(&behavior_pack)?; + let scripts_package_name = scripts_package + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| { + CliError::InvalidData(format!( + "scripts package 目录名非 UTF-8: '{}'", + scripts_package.display() + )) + })? + .to_string(); + + Ok(Layout { + behavior_pack, + resource_pack, + scripts_package, + scripts_package_name, + }) +} + +pub struct Layout { + pub behavior_pack: PathBuf, + pub resource_pack: PathBuf, + pub scripts_package: PathBuf, + pub scripts_package_name: String, +} + +fn find_single_dir(parent: &Path, prefix: &str) -> Result { + let mut matches = Vec::new(); + for entry in std::fs::read_dir(parent)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.starts_with(prefix) { + matches.push(path); + } + } + } + } + + match matches.len() { + 0 => Err(CliError::NotFound(format!( + "在 '{}' 下未找到 {}* 目录", + parent.display(), + prefix + ))), + 1 => Ok(matches.into_iter().next().unwrap()), + _ => Err(CliError::InvalidData(format!( + "在 '{}' 下找到多个 {}* 目录", + parent.display(), + prefix + ))), + } +} + +fn find_scripts_package(behavior_pack: &Path) -> Result { + let mut matches = Vec::new(); + for entry in std::fs::read_dir(behavior_pack)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() && path.join("modMain.py").is_file() { + matches.push(path); + } + } + + match matches.len() { + 0 => Err(CliError::NotFound(format!( + "在 '{}' 下未找到含 modMain.py 的 scripts package", + behavior_pack.display() + ))), + 1 => Ok(matches.into_iter().next().unwrap()), + _ => Err(CliError::InvalidData(format!( + "在 '{}' 下找到多个含 modMain.py 的目录", + behavior_pack.display() + ))), + } +} + +/// PascalCase → snake_case: AbyssBossEngine → abyss_boss_engine +pub fn pascal_to_snake(s: &str) -> String { + let mut result = String::new(); + for (i, ch) in s.chars().enumerate() { + if ch.is_uppercase() && i > 0 { + result.push('_'); + } + result.extend(ch.to_lowercase()); + } + result +} + +/// 首字母小写: AbyssBossEngine → abyssBossEngine +pub fn lower_first_char(s: &str) -> Result { + let mut chars = s.chars(); + let first = chars + .next() + .ok_or_else(|| CliError::InvalidInput("name 不能为空".to_string()))?; + Ok(format!( + "{}{}", + first.to_lowercase().collect::(), + chars.as_str() + )) +} diff --git a/src/commands/fork_boss_engine.rs b/src/commands/fork_boss_engine.rs new file mode 100644 index 0000000..7e4135c --- /dev/null +++ b/src/commands/fork_boss_engine.rs @@ -0,0 +1,486 @@ +use std::path::{Path, PathBuf}; +use std::{env, fs}; + +use uuid::Uuid; +use walkdir::WalkDir; + +use crate::boss_engine::{self, copy}; +use crate::commands::ForkBossEngineArgs; +use crate::error::{CliError, Result}; +use crate::utils::file; + +const BOSS_ENGINE_PATH_ENV: &str = "ABYSS_BOSS_ENGINE_PATH"; +const OLD_NAMESPACE: &str = "abyss_boss_engine"; + +const EXCLUDE_DIRS: &[&str] = &[ + ".git", + "target", + "__pycache__", + ".pytest_cache", + ".codewiki", + ".omo", + ".idea", + ".opencode", + ".agents", + ".claude", + ".qoder", + "openspec", + ".emod-cli", +]; + +const EXCLUDE_FILES: &[&str] = &[".mcdev.json", "AGENTS.md"]; + +pub fn execute(args: &ForkBossEngineArgs) { + if let Err(e) = run_fork(args) { + eprintln!("Error: {}", e); + return; + } + println!("Success: boss engine forked."); +} + +fn run_fork(args: &ForkBossEngineArgs) -> Result<()> { + validate_project_name(&args.name)?; + + let from_str = resolve_from(&args.from)?; + let from_dir = file::find_project_dir(&Some(from_str))?; + validate_engine_source(&from_dir)?; + + let src_layout = boss_engine::discover_layout(&from_dir)?; + let old_scripts_name = src_layout.scripts_package_name.clone(); + let old_bp_dir_name = src_layout + .behavior_pack + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| CliError::InvalidData("BP 目录名非 UTF-8".into()))? + .to_string(); + let old_rp_dir_name = src_layout + .resource_pack + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| CliError::InvalidData("RP 目录名非 UTF-8".into()))? + .to_string(); + + let namespace = args + .namespace + .clone() + .unwrap_or_else(|| boss_engine::pascal_to_snake(&args.name)); + validate_namespace(&namespace)?; + let new_scripts_name = format!("{}Scripts", boss_engine::lower_first_char(&args.name)?); + + let output_dir = PathBuf::from(format!("./{}", args.name)); + if output_dir.exists() { + return Err(CliError::InvalidInput(format!( + "输出目录已存在: '{}'", + output_dir.display() + ))); + } + let source_dir = from_dir.canonicalize()?; + let output_dir_abs = env::current_dir()?.canonicalize()?.join(&args.name); + if output_dir_abs.starts_with(&source_dir) { + return Err(CliError::InvalidInput(format!( + "输出目录不能位于框架源目录内: '{}'", + output_dir_abs.display() + ))); + } + + println!("Forking boss engine:"); + println!(" from: {}", from_dir.display()); + println!(" to: {}", output_dir.display()); + println!(" name: {}", args.name); + println!(" ns: {}", namespace); + + copy::copy_dir_filtered(&from_dir, &output_dir, EXCLUDE_DIRS, EXCLUDE_FILES)?; + + let uuids = generate_uuids(); + update_manifests_and_packs(&output_dir, &uuids)?; + + let new_bp_dir_name = format!("behavior_pack_{}", uuids.bp_short); + let new_rp_dir_name = format!("resource_pack_{}", uuids.rp_short); + rename_dir(&output_dir, &old_bp_dir_name, &new_bp_dir_name)?; + rename_dir(&output_dir, &old_rp_dir_name, &new_rp_dir_name)?; + + let new_bp_dir = output_dir.join(&new_bp_dir_name); + rename_dir(&new_bp_dir, &old_scripts_name, &new_scripts_name)?; + + let new_scripts_dir = new_bp_dir.join(&new_scripts_name); + + if args.keep_example { + replace_namespace_everywhere(&output_dir, OLD_NAMESPACE, &namespace)?; + } else { + let new_rp_dir = output_dir.join(&new_rp_dir_name); + remove_example_assets(&new_bp_dir, &new_rp_dir, &new_scripts_dir)?; + write_clean_modconfig(&new_scripts_dir, &namespace)?; + write_clean_server_system(&new_scripts_dir)?; + clean_lang_file(&new_rp_dir)?; + } + + print_summary(&uuids, &old_scripts_name, &new_scripts_name, &args.name); + + Ok(()) +} + +fn validate_project_name(name: &str) -> Result<()> { + let mut chars = name.chars(); + let valid = chars + .next() + .map(|first| first.is_ascii_uppercase()) + .unwrap_or(false) + && chars.all(|ch| ch.is_ascii_alphanumeric()); + if !valid { + return Err(CliError::InvalidInput( + "name 必须是仅含 ASCII 字母和数字的 PascalCase 名称".to_string(), + )); + } + Ok(()) +} + +fn validate_namespace(namespace: &str) -> Result<()> { + let mut chars = namespace.chars(); + let valid = chars + .next() + .map(|first| first.is_ascii_lowercase()) + .unwrap_or(false) + && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_'); + if !valid { + return Err(CliError::InvalidInput( + "namespace 必须以小写 ASCII 字母开头,且仅包含小写字母、数字和下划线".to_string(), + )); + } + Ok(()) +} + +fn validate_engine_source(project_dir: &Path) -> Result<()> { + let layout = boss_engine::discover_layout(project_dir)?; + let marker = layout + .scripts_package + .join("boss_engine") + .join("server") + .join("manager.py"); + if !marker.is_file() { + return Err(CliError::InvalidInput(format!( + "'{}' 不是有效的 AbyssBossEngine 项目: 缺少 boss_engine/server/manager.py", + project_dir.display() + ))); + } + Ok(()) +} + +struct NewUuids { + bp_header: String, + bp_module: String, + rp_header: String, + rp_module: String, + bp_short: String, + rp_short: String, +} + +fn generate_uuids() -> NewUuids { + let bp_header = Uuid::new_v4().to_string(); + let rp_header = Uuid::new_v4().to_string(); + NewUuids { + bp_short: bp_header.chars().take(8).collect(), + rp_short: rp_header.chars().take(8).collect(), + bp_module: Uuid::new_v4().to_string(), + rp_module: Uuid::new_v4().to_string(), + bp_header, + rp_header, + } +} + +fn update_manifests_and_packs(output_dir: &Path, uuids: &NewUuids) -> Result<()> { + let bp_dir = find_pack_dir(output_dir, "behavior_pack_")?; + let rp_dir = find_pack_dir(output_dir, "resource_pack_")?; + + file::update_json_file(&bp_dir.join("pack_manifest.json"), |json| { + json["header"]["uuid"] = uuids.bp_header.clone().into(); + json["modules"][0]["uuid"] = uuids.bp_module.clone().into(); + json["dependencies"][0]["uuid"] = uuids.rp_header.clone().into(); + Ok(()) + })?; + + file::update_json_file(&rp_dir.join("pack_manifest.json"), |json| { + json["header"]["uuid"] = uuids.rp_header.clone().into(); + json["modules"][0]["uuid"] = uuids.rp_module.clone().into(); + Ok(()) + })?; + + file::update_json_file(&output_dir.join("world_behavior_packs.json"), |json| { + json[0]["pack_id"] = uuids.bp_header.clone().into(); + Ok(()) + })?; + + file::update_json_file(&output_dir.join("world_resource_packs.json"), |json| { + json[0]["pack_id"] = uuids.rp_header.clone().into(); + Ok(()) + })?; + + Ok(()) +} + +fn find_pack_dir(parent: &Path, prefix: &str) -> Result { + for entry in fs::read_dir(parent)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.starts_with(prefix) { + return Ok(path); + } + } + } + } + Err(CliError::NotFound(format!( + "在 '{}' 下未找到 {}* 目录", + parent.display(), + prefix + ))) +} + +fn rename_dir(parent: &Path, old_name: &str, new_name: &str) -> Result<()> { + let old_path = parent.join(old_name); + let new_path = parent.join(new_name); + if !old_path.exists() { + return Err(CliError::NotFound(format!( + "待重命名目录不存在: '{}'", + old_path.display() + ))); + } + fs::rename(&old_path, &new_path)?; + Ok(()) +} + +// ── example 处理 ────────────────────────────────────────── + +fn remove_example_assets(bp_dir: &Path, rp_dir: &Path, scripts_dir: &Path) -> Result<()> { + let example_prefix = format!("{}_example", OLD_NAMESPACE); + + let mut deleted = 0usize; + for root in [bp_dir, rp_dir] { + for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) { + let path = entry.path(); + if !path.is_file() { + continue; + } + let file_name = match path.file_name().and_then(|n| n.to_str()) { + Some(n) => n, + None => continue, + }; + if file_name.starts_with(&example_prefix) { + fs::remove_file(path)?; + deleted += 1; + } + } + } + + let example_scripts_dir = scripts_dir.join("modServer").join("example"); + if example_scripts_dir.is_dir() { + fs::remove_dir_all(&example_scripts_dir)?; + } + + println!(" removed {} example asset files", deleted); + Ok(()) +} + +fn write_clean_modconfig(scripts_dir: &Path, namespace: &str) -> Result<()> { + let content = format!( + r#"# -*- coding: utf-8 -*- + +MOD_ID = "{ns}" +MOD_VERSION = "1.0.0" +SCRIPT_PACKAGE = __name__.split(".")[0] + +PROJECT_NAME = "AiyoBoss_%s" % MOD_ID +SERVER_SYSTEM_NAME = "AiyoBossServer_%s" % MOD_ID +CLIENT_SYSTEM_NAME = "AiyoBossClient_%s" % MOD_ID + +SERVER_SYSTEM_PATH = "%s.modServer.serverSystem.BossModServerSystem" % SCRIPT_PACKAGE +CLIENT_SYSTEM_PATH = "%s.modClient.clientSystem.BossModClientSystem" % SCRIPT_PACKAGE +BROKER_SYSTEM_PATH = "%s.boss_engine.client.broker_system.BossClientBroker" % SCRIPT_PACKAGE + +BOSS_REGISTRY_EVENT = "AiyoBossRegistryV1" +BGM_SNAPSHOT_EVENT = "AiyoBossBgmSnapshotV1" +CLIENT_READY_EVENT = "AiyoBossClientReadyV1" + +MAX_SUMMONS_PER_BOSS = 8 +SUMMON_TARGET_SYNC_INTERVAL = 0.5 +"#, + ns = namespace + ); + + let path = scripts_dir.join("modCommon").join("modConfig.py"); + fs::write(&path, content)?; + Ok(()) +} + +fn write_clean_server_system(scripts_dir: &Path) -> Result<()> { + let content = r#"# -*- coding: utf-8 -*- +import mod.server.extraServerApi as serverApi + +from ..boss_engine.server import BossManager +from ..modCommon import modConfig + + +ServerSystem = serverApi.GetServerSystemCls() + + +class BossModServerSystem(ServerSystem): + def __init__(self, namespace, system_name): + ServerSystem.__init__(self, namespace, system_name) + self.boss_manager = BossManager( + self, + modConfig.MOD_ID, + modConfig.PROJECT_NAME, + modConfig.CLIENT_SYSTEM_NAME, + modConfig.BOSS_REGISTRY_EVENT, + modConfig.BGM_SNAPSHOT_EVENT, + modConfig.CLIENT_READY_EVENT, + modConfig.MAX_SUMMONS_PER_BOSS, + modConfig.SUMMON_TARGET_SYNC_INTERVAL, + ) + self.boss_manager.start() + print("[AbyssBossEngine][%s] server ready" % modConfig.MOD_ID) + + def Destroy(self): + self.boss_manager.destroy() +"#; + + let path = scripts_dir.join("modServer").join("serverSystem.py"); + fs::write(&path, content)?; + Ok(()) +} + +fn clean_lang_file(rp_dir: &Path) -> Result<()> { + let lang_path = rp_dir.join("texts").join("zh_CN.lang"); + if !lang_path.is_file() { + return Ok(()); + } + let content = fs::read_to_string(&lang_path)?; + let filtered: String = content + .lines() + .filter(|line| !line.contains(OLD_NAMESPACE)) + .collect::>() + .join("\n"); + fs::write(&lang_path, filtered + "\n")?; + Ok(()) +} + +// ── keep-example 模式: namespace 全局替换 ────────────────── + +fn replace_namespace_everywhere(output_dir: &Path, old_ns: &str, new_ns: &str) -> Result<()> { + let bp_dir = find_pack_dir(output_dir, "behavior_pack_")?; + let rp_dir = find_pack_dir(output_dir, "resource_pack_")?; + + let mut replaced_files = 0usize; + for root in [&bp_dir, &rp_dir] { + replaced_files += replace_in_text_files(root, old_ns, new_ns)?; + } + + let mut renamed_files = 0usize; + for root in [&bp_dir, &rp_dir] { + renamed_files += rename_files_with_namespace(root, old_ns, new_ns)?; + } + + println!( + " namespace replaced: {} -> {} ({} files updated, {} renamed)", + old_ns, new_ns, replaced_files, renamed_files + ); + Ok(()) +} + +fn replace_in_text_files(root: &Path, old_ns: &str, new_ns: &str) -> Result { + let mut count = 0usize; + for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) { + let path = entry.path(); + if !path.is_file() { + continue; + } + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => continue, + }; + if !content.contains(old_ns) { + continue; + } + let updated = content.replace(old_ns, new_ns); + fs::write(path, updated)?; + count += 1; + } + Ok(count) +} + +fn rename_files_with_namespace(root: &Path, old_ns: &str, new_ns: &str) -> Result { + let mut to_rename = Vec::new(); + for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) { + let path = entry.path(); + if !path.is_file() { + continue; + } + let file_name = match path.file_name().and_then(|n| n.to_str()) { + Some(n) => n, + None => continue, + }; + if file_name.contains(old_ns) { + to_rename.push(path.to_path_buf()); + } + } + + for path in &to_rename { + let dir = path.parent().unwrap(); + let old_name = path.file_name().unwrap().to_str().unwrap(); + let new_name = old_name.replace(old_ns, new_ns); + fs::rename(path, dir.join(new_name))?; + } + + Ok(to_rename.len()) +} + +// ── 输出 ────────────────────────────────────────────────── + +fn print_summary(uuids: &NewUuids, old_scripts: &str, new_scripts: &str, project_name: &str) { + println!("\nUUID changes:"); + println!(" BP header: {}", uuids.bp_header); + println!(" BP module: {}", uuids.bp_module); + println!(" RP header: {}", uuids.rp_header); + println!(" RP module: {}", uuids.rp_module); + println!("\nScripts package: {} -> {}", old_scripts, new_scripts); + println!("\nNext steps:"); + println!(" cd {}", project_name); + println!(" python -m pytest tests -q"); +} + +fn resolve_from(from: &Option) -> Result { + if let Some(path) = from { + if !path.is_empty() { + return Ok(path.clone()); + } + } + match env::var(BOSS_ENGINE_PATH_ENV) { + Ok(value) if !value.is_empty() => { + println!(" (from env ${}: {})", BOSS_ENGINE_PATH_ENV, value); + Ok(value) + } + _ => Err(CliError::InvalidInput(format!( + "未指定框架源路径: 请使用 --from 或设置环境变量 {}", + BOSS_ENGINE_PATH_ENV + ))), + } +} + +#[cfg(test)] +mod tests { + use super::{validate_namespace, validate_project_name}; + + #[test] + fn accepts_valid_project_names_and_namespaces() { + assert!(validate_project_name("ShadowBoss2").is_ok()); + assert!(validate_namespace("shadow_boss2").is_ok()); + } + + #[test] + fn rejects_paths_and_invalid_namespaces() { + assert!(validate_project_name("../ShadowBoss").is_err()); + assert!(validate_project_name("shadowBoss").is_err()); + assert!(validate_namespace("ShadowBoss").is_err()); + assert!(validate_namespace("2shadow_boss").is_err()); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 44e06a8..69672d8 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod bbmodel; pub mod components; pub mod create; pub mod debug; +pub mod fork_boss_engine; pub mod init; pub mod release; @@ -35,6 +36,8 @@ pub enum Commands { Debug(DebugArgs), /// Convert Blockbench .bbmodel to NetEase block geometry Bbmodel(BbmodelArgs), + /// Fork AbyssBossEngine into a new boss mod project + ForkBossEngine(ForkBossEngineArgs), } #[derive(Args)] @@ -118,3 +121,20 @@ pub struct DebugArgs { #[arg(short = 'n', long = "new")] pub new_world: bool, } + +#[derive(Args)] +pub struct ForkBossEngineArgs { + /// Path to the AbyssBossEngine source project. + /// Falls back to env ABYSS_BOSS_ENGINE_PATH when omitted. + #[arg(long)] + pub from: Option, + /// New project name (PascalCase, e.g. MyShadowBoss) + #[arg(short, long)] + pub name: String, + /// Override the entity namespace (default: snake_case of --name) + #[arg(long)] + pub namespace: Option, + /// Keep example boss as reference (default: remove) + #[arg(long)] + pub keep_example: bool, +} diff --git a/src/main.rs b/src/main.rs index edf8531..8792e14 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod boss_engine; mod commands; mod debug; mod entity; @@ -17,5 +18,6 @@ fn main() { Commands::Components(args) => commands::components::execute(args), Commands::Debug(args) => commands::debug::execute(args), Commands::Bbmodel(args) => commands::bbmodel::execute(args), + Commands::ForkBossEngine(args) => commands::fork_boss_engine::execute(args), } }