feat(boss-engine): 增加项目派生命令

新增 fork-boss-engine,从 AbyssBossEngine 源项目生成独立 BOSS 模组,并重建 UUID、命名空间和脚本包。

校验项目名称与命名空间,同时阻止输出目录落入源目录,避免路径穿越和递归复制。
This commit is contained in:
2026-07-21 20:17:49 +08:00
parent 410b2e64e9
commit bc9221d4cc
5 changed files with 657 additions and 0 deletions

35
src/boss_engine/copy.rs Normal file
View File

@@ -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(())
}

114
src/boss_engine/mod.rs Normal file
View File

@@ -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<Layout> {
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<PathBuf> {
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<PathBuf> {
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<String> {
let mut chars = s.chars();
let first = chars
.next()
.ok_or_else(|| CliError::InvalidInput("name 不能为空".to_string()))?;
Ok(format!(
"{}{}",
first.to_lowercase().collect::<String>(),
chars.as_str()
))
}

View File

@@ -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<PathBuf> {
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::<Vec<_>>()
.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<usize> {
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<usize> {
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<String>) -> Result<String> {
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());
}
}

View File

@@ -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<String>,
/// 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<String>,
/// Keep example boss as reference (default: remove)
#[arg(long)]
pub keep_example: bool,
}

View File

@@ -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),
}
}