feat(boss-engine): 增加项目派生命令
新增 fork-boss-engine,从 AbyssBossEngine 源项目生成独立 BOSS 模组,并重建 UUID、命名空间和脚本包。 校验项目名称与命名空间,同时阻止输出目录落入源目录,避免路径穿越和递归复制。
This commit is contained in:
486
src/commands/fork_boss_engine.rs
Normal file
486
src/commands/fork_boss_engine.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user