diff --git a/Cargo.lock b/Cargo.lock index 35ae8cc..c7bd8f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -586,7 +586,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.9.9" +version = "0.9.10" dependencies = [ "base64 0.22.1", "chrono", @@ -634,7 +634,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.9.9" +version = "0.9.10" dependencies = [ "chrono", "coa-core", @@ -772,7 +772,7 @@ dependencies = [ [[package]] name = "csm-modules-preview" -version = "0.9.9" +version = "0.9.10" dependencies = [ "coa-control-proto", "coa-core", diff --git a/Cargo.toml b/Cargo.toml index 05daa91..be96eef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/coa-core", "crates/coa-release", "crates/coa-registry-proto", exclude = ["services/coa-registry", "services/coa-registry-e2e", "services/coa-coordinator", "services/coa-relay"] [workspace.package] -version = "0.9.9" +version = "0.9.10" edition = "2021" license = "AGPL-3.0-only" diff --git a/changelog.d/20261106-dungeon-clear-part-of-squid.md b/changelog.d/20261106-dungeon-clear-part-of-squid.md new file mode 100644 index 0000000..a19a99e --- /dev/null +++ b/changelog.d/20261106-dungeon-clear-part-of-squid.md @@ -0,0 +1,8 @@ +--- +area: manager +type: changed +audience: admins +title: Dungeon Clear is shown and switched as part of SQUID's Playerbots +--- +Dungeon Clear no longer has a card of its own, which said "Always on" while SQUID was off. It goes on +and off together with SQUID's Playerbots, and its settings open from the SQUID card. diff --git a/changelog.d/20261106-preview-bare-core-and-modular-copy.md b/changelog.d/20261106-preview-bare-core-and-modular-copy.md new file mode 100644 index 0000000..33cda79 --- /dev/null +++ b/changelog.d/20261106-preview-bare-core-and-modular-copy.md @@ -0,0 +1,10 @@ +--- +area: manager +type: changed +audience: admins +title: Modules Preview installs the bare modular core and copies classic servers instead of converting them +--- +A new server installed by the Modules Preview goes straight on to the modular core and compiles no +module; modules are added later from the Modules page. Choosing an existing server with the classic +core no longer adds it as it is: a modular server is installed next to it and its accounts, characters, +world, bots and module settings are copied into it. The old server is only read and keeps working. diff --git a/crates/coa-core/examples/preview_bare_and_copy_e2e.rs b/crates/coa-core/examples/preview_bare_and_copy_e2e.rs new file mode 100644 index 0000000..2d10632 --- /dev/null +++ b/crates/coa-core/examples/preview_bare_and_copy_e2e.rs @@ -0,0 +1,177 @@ +//! 0.9.10 install paths of the Modules Preview, end to end on DISPOSABLE servers under : +//! +//! 1. fresh: a new server goes on to the preview core with the bare core (no module is compiled). +//! 2. copy: a classic server (stopped) gets a modular copy next to it: accounts, characters, the world and +//! module settings arrive, the old server's files are byte-for-byte unchanged, the copy takes the +//! preview core with the modules the old server had switched on. +//! 3. copy-running: the same copy step from a classic server whose database is running. +//! +//! usage: preview_bare_and_copy_e2e [fresh|copy|all] +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use coa_core::install::{install_base, Params, Source}; +use coa_core::registry::Registry; +use coa_core::update::{self, RepackEnv}; + +const KEY: &str = coa_core::signing::EMBEDDED_PUBLIC_KEY; +type R = Result>; + +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port() +} + +fn sql(root: &Path, q: &str) -> String { + coa_core::backup::with_database(root, |db| db.query(q)).unwrap_or_else(|e| panic!("{q}: {e}")) +} + +fn install(root: &Path, reg: &Registry) -> R<()> { + let image = coa_core::channels::stable_source()?; + let t = std::time::Instant::now(); + let done = install_base( + &Params { source: Source::Installation(Box::new(image)), dest: root.into(), trusted_key: KEY, registry: reg, cancel: Default::default() }, + &|s| eprintln!(" {:>3}% {}", s.percent, s.step), + )?; + println!("[{}] installed {} in {:?}", root.display(), done.version, t.elapsed()); + Ok(()) +} + +fn set_free_ports(root: &Path) -> R<()> { + let repack = root.join("Settings/repack.json"); + let mut cfg: serde_json::Value = serde_json::from_slice(&std::fs::read(&repack)?)?; + for k in ["mysqlPort", "authPort", "worldPort", "raPort"] { + cfg[k] = free_port().into(); + } + std::fs::write(&repack, serde_json::to_vec_pretty(&cfg)?)?; + Ok(()) +} + +/// The same steps as the Manager's install_preview_core. +fn preview_core(root: &Path) -> R { + let meta_dir = coa_core::registry::metadata_dir_for(root)?; + let (_, meta) = coa_core::registry::MetaDir::open(&meta_dir)?; + let source = coa_core::update_route::select(&meta, coa_core::channels::preview_update_source()?, KEY)?.source; + let env = RepackEnv { root, meta_dir: &meta_dir }; + let t = std::time::Instant::now(); + let out = coa_core::install::with_scratch_ports(root, || update::apply( + &update::Params { root, meta_dir: &meta_dir, source, trusted_key: KEY, cancel: Default::default(), resolutions: Default::default(), env: &env, fail_after_ops: None }, + &|phase, percent| println!(" {percent}% {phase}"), + )); + let _ = coa_core::driver::run(root, coa_core::driver::Verb::StopAll); + let out = out?; + println!("[{}] preview core: {:?} in {:?}; modules {:?}", root.display(), out.txn.state, t.elapsed(), out.modules); + assert_eq!(out.txn.state, update::State::Committed); + assert!(coa_core::provenance::realm_host_fingerprint(root).is_some(), "the plugin host is installed"); + Ok(out) +} + +/// sha256 of every file under the old server, except the launcher's own run state. +fn tree(root: &Path) -> BTreeMap { + fn walk(base: &Path, dir: &Path, out: &mut BTreeMap) { + for e in std::fs::read_dir(dir).into_iter().flatten().flatten() { + let p = e.path(); + if p.is_dir() { + walk(base, &p, out); + } else { + let rel = p.strip_prefix(base).unwrap().to_string_lossy().replace('\\', "/"); + out.insert(rel, coa_core::fsx::sha256_file(&p).unwrap_or_else(|e| format!("unreadable: {e}"))); + } + } + } + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +fn fresh(work: &Path, reg: &Registry) -> R<()> { + let root = work.join("fresh-fixture"); + install(&root, reg)?; // shipped ports, as in the Manager: another server may be running on them + coa_core::rebuild::decline_recommended(&root)?; + let out = preview_core(&root)?; + assert!(out.modules.is_empty(), "a new server compiles nothing: {:?}", out.modules); + let registry = coa_core::plugins::load_registry(&root)?; + assert!(registry.plugins.values().all(|e| !e.installed), "no plugin installed: {:?}", registry.plugins.keys()); + let boot: serde_json::Value = serde_json::from_slice(&std::fs::read(root.join("Core/plugins/plugin-boot-report.json"))?)?; + println!("boot report: {boot}"); + assert_eq!(boot["anyFaulted"], serde_json::json!(false)); + println!("FRESH PASS"); + Ok(()) +} + +fn copy(work: &Path, reg: &Registry) -> R<()> { + // A classic server with data of its own. + let old = work.join("classic-fixture"); + install(&old, reg)?; + set_free_ports(&old)?; // the test runs this "old" server; it must not meet a real one on the shipped ports + sql(&old, "INSERT INTO acore_auth.account (username, salt, verifier, email, reg_mail) \ + VALUES ('E2ECOPY', UNHEX(REPEAT('11',32)), UNHEX(REPEAT('22',32)), '', '')"); + let account = sql(&old, "SELECT id FROM acore_auth.account WHERE username='E2ECOPY'").trim().to_string(); + sql(&old, &format!("SET SESSION sql_mode=''; INSERT INTO acore_characters.characters (guid, account, name, race, class, gender, level, money) \ + VALUES (900001, {account}, 'Copytest', 1, 1, 0, 42, 123456)")); + let conf = old.join("Core/configs/modules/mod-coa-content-scaling.conf"); + let conf_text = std::fs::read_to_string(&conf).unwrap_or_default() + "\n# e2e owner edit\n"; + std::fs::write(&conf, &conf_text)?; + let fingerprint = |root: &Path| sql(root, "SELECT CONCAT((SELECT COUNT(*) FROM acore_auth.account),'|',(SELECT COUNT(*) FROM acore_characters.characters),'|',\ + (SELECT CONCAT(name,':',level,':',money) FROM acore_characters.characters WHERE guid=900001))").trim().to_string(); + let expected = fingerprint(&old); + let _ = coa_core::driver::run(&old, coa_core::driver::Verb::StopAll); + println!("old server data: {expected}"); + assert!(coa_core::modular_copy::is_classic(&old)); + + // 2. copy of the stopped server, then the preview core on the copy. + let before = tree(&old); + let new = work.join("copy-fixture"); + install(&new, reg)?; + let report = coa_core::modular_copy::copy_data(&old, &new, &|w| println!(" copy: {w}"))?; + println!("copy report: {report:?}"); + assert_eq!(report.read_from, "copy of the stopped database"); + assert_eq!(tree(&old), before, "the old server's files changed"); + let observed = coa_core::process::observe(&new, &coa_core::layout::read_ports(&new)); + assert_eq!(observed.mysql.state, coa_core::process::ServiceState::Stopped, "a database of the copy is still running: {:?}", observed.mysql); + assert!(!coa_core::registry::metadata_dir_for(&new)?.join("import").exists(), "the private copy of the old data is removed"); + assert_eq!(std::fs::read_to_string(new.join("Core/configs/modules/mod-coa-content-scaling.conf"))?, conf_text); + let out = preview_core(&new)?; + println!("modules on the copy: {:?}", out.modules); + let got = fingerprint(&new); + let _ = coa_core::driver::run(&new, coa_core::driver::Verb::StopAll); + assert_eq!(got, expected, "data of the copy after the preview core"); + assert_eq!(tree(&old), before, "the old server's files changed"); + println!("COPY (stopped) PASS"); + + // 3. copy while the old database runs: read from it, change nothing. + let started = coa_core::driver::run(&old, coa_core::driver::Verb::StartMysql)?; + assert!(started.ok, "{}", started.output); + let data_before: BTreeMap = tree(&old).into_iter().filter(|(k, _)| !k.starts_with("mysql/") && !k.starts_with(".state/") && !k.starts_with("Core/configs/")).collect(); + let rows_before = fingerprint(&old); + let new2 = work.join("copy-running-fixture"); + install(&new2, reg)?; + let report = coa_core::modular_copy::copy_data(&old, &new2, &|w| println!(" copy: {w}")); + let rows_after = fingerprint(&old); + let _ = coa_core::driver::run(&old, coa_core::driver::Verb::StopAll); + let report = report?; + assert_eq!(report.read_from, "running database"); + assert_eq!(rows_after, rows_before); + let data_after: BTreeMap = tree(&old).into_iter().filter(|(k, _)| !k.starts_with("mysql/") && !k.starts_with(".state/") && !k.starts_with("Core/configs/")).collect(); + assert_eq!(data_after, data_before, "files outside the database changed"); + assert_eq!(fingerprint(&new2), expected); + let _ = coa_core::driver::run(&new2, coa_core::driver::Verb::StopAll); + println!("COPY (running) PASS"); + Ok(()) +} + +fn main() -> R<()> { + let a: Vec = std::env::args().skip(1).collect(); + let work = PathBuf::from(a.first().expect("usage: preview_bare_and_copy_e2e [fresh|copy|all]")); + let mode = a.get(1).map(String::as_str).unwrap_or("all"); + assert!(!work.exists() || std::fs::read_dir(&work)?.next().is_none(), "work folder must be new or empty"); + std::fs::create_dir_all(&work)?; + let reg = Registry::at(work.join("installs.json")); + if mode == "fresh" || mode == "all" { + fresh(&work, ®)?; + } + if mode == "copy" || mode == "all" { + copy(&work, ®)?; + } + println!("ALL PASS ({mode})"); + Ok(()) +} diff --git a/crates/coa-core/src/db.rs b/crates/coa-core/src/db.rs index 2bdd46b..176c2f0 100644 --- a/crates/coa-core/src/db.rs +++ b/crates/coa-core/src/db.rs @@ -337,6 +337,17 @@ impl Db { .map(|o| o.status.success()).unwrap_or(false) } + /// Ask the server to shut down cleanly (`mysqladmin shutdown`). + pub fn shutdown_server(&self) -> Result<()> { + let mut c = self.command("mysqladmin.exe"); + c.arg("shutdown").arg("--connect-timeout=5"); + let out = bounded_client_output(&mut c, b"", Instant::now() + Duration::from_secs(60))?; + if !out.status.success() { + return Err(self.fail(&out.stderr)); + } + Ok(()) + } + pub(crate) fn with_read_deadline(mut self, deadline: Instant) -> Self { self.read_deadline = Some(deadline); self diff --git a/crates/coa-core/src/install.rs b/crates/coa-core/src/install.rs index f027005..806ee29 100644 --- a/crates/coa-core/src/install.rs +++ b/crates/coa-core/src/install.rs @@ -210,7 +210,7 @@ pub(crate) fn free_port() -> Result { /// Run `f` with the staging copy listening on unused ports, so an installation never collides with a server (or any /// other program) already using the shipped ports. The shipped ports are put back afterwards. -pub(crate) fn with_scratch_ports(root: &Path, f: impl FnOnce() -> Result) -> Result { +pub fn with_scratch_ports(root: &Path, f: impl FnOnce() -> Result) -> Result { const KEYS: [&str; 4] = ["mysqlPort", "authPort", "worldPort", "raPort"]; let path = root.join("Settings/repack.json"); let original: serde_json::Value = fsx::read_json(&path)?; @@ -378,15 +378,18 @@ pub fn install_base(p: &Params, report: &dyn Fn(Step)) -> Result { })?; say("Preparing database", 82, None); bootstrap_database(&staging_root)?; - crate::backup::with_database(&staging_root, |db| { - let problems = crate::schema_check::check(db, &staging_root)?; - if let Some(p) = problems.first() { - return Err(Error::Invalid(format!( - "The packaged database is incomplete: {}.{}.{}: {}.", - p.database, p.table, p.column, p.detail - ))); - } - Ok(()) + // On unused ports too: another server running on the shipped ports must not stop the installation. + with_scratch_ports(&staging_root, || { + crate::backup::with_database(&staging_root, |db| { + let problems = crate::schema_check::check(db, &staging_root)?; + if let Some(p) = problems.first() { + return Err(Error::Invalid(format!( + "The packaged database is incomplete: {}.{}.{}: {}.", + p.database, p.table, p.column, p.detail + ))); + } + Ok(()) + }) })?; // 4. Commit: the only moment the destination changes. The marker stays until everything is done, so an // unfinished install can always be recognised as ours (and cleaned up), wherever it stopped. diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index 3ccefae..c07e8bb 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -43,6 +43,7 @@ pub mod logging; pub mod server_logging; pub mod manifest; pub mod migrations; +pub mod modular_copy; pub mod modules; pub mod module_sources; pub mod msvc_setup; diff --git a/crates/coa-core/src/modular_copy.rs b/crates/coa-core/src/modular_copy.rs new file mode 100644 index 0000000..23820aa --- /dev/null +++ b/crates/coa-core/src/modular_copy.rs @@ -0,0 +1,336 @@ +//! A modular copy of a classic server, next to it. +//! +//! A server with the classic core (every module compiled into `worldserver.exe`) is never converted in place. +//! The Modules Preview installs a new server beside it and copies the old one's data into it: every game +//! database (accounts, characters, world, bots) and the module settings. The old server is only read: +//! +//! * if its database is running, the schemas are dumped from it (consistent snapshots, nothing is written); +//! * if it is stopped, its data files are copied to a private folder and read by a temporary database +//! process of the NEW server; nothing of the old server is started or executed. +//! +//! The new server then takes the preview core like any other server (the caller applies the update). + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use serde::Serialize; + +use crate::db::{Account, Db}; +use crate::error::{Error, Result}; +use crate::fsx; +use crate::process::ServiceState; + +/// Whether `root` runs the classic core: a Windows repack whose `worldserver.exe` carries no plugin-host +/// fingerprint. +pub fn is_classic(root: &Path) -> bool { + !crate::docker::is_docker(root) + && root.join("Core/worldserver.exe").is_file() + && crate::provenance::realm_host_fingerprint(root).is_none() +} + +#[derive(Debug, Clone, Serialize)] +pub struct CopyReport { + pub schemas: Vec, + pub accounts: u64, + pub characters: u64, + pub config_files: Vec, + /// How the old data was read: "running database" or "copy of the stopped database". + pub read_from: String, +} + +/// Game schemas of a CoA server: `acore_*` (auth, characters, world, their Wildcard/Reborn variants, bots). +fn game_schemas(db: &Db) -> Result> { + let mut out: Vec = db + .query("SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME LIKE 'acore\\_%' ORDER BY SCHEMA_NAME;")? + .lines() + .map(|l| l.trim().to_string()) + .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')) + // work schemas a restore leaves behind are not game data + .filter(|s| !s.contains("_restore_") && !s.contains("_before_restore_")) + .collect(); + out.dedup(); + if !out.iter().any(|s| s == "acore_auth") || !out.iter().any(|s| s == "acore_characters") { + return Err(Error::Invalid("The old server's database has no accounts or characters schema.".into())); + } + Ok(out) +} + +fn count(db: &Db, sql: &str) -> u64 { + db.query(sql).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0) +} + +/// A database process of the new server reading a private copy of the old server's stopped data files. +struct TemporaryDatabase { + child: Child, + dir: PathBuf, + db: Option, + stopped: bool, +} + +impl TemporaryDatabase { + /// Shut it down cleanly, force it if it does not go, and remove the private copy. Fails if the process is + /// still there afterwards, so a leftover database never holds the new server's files. + fn stop(&mut self) -> Result<()> { + if self.stopped { + return Ok(()); + } + if let Some(db) = &self.db { + let _ = db.shutdown_server(); + } + let deadline = Instant::now() + Duration::from_secs(90); + loop { + match self.child.try_wait()? { + Some(_) => break, + None if Instant::now() > deadline => { + tracing::warn!(pid = self.child.id(), "temporary database did not shut down; terminating it"); + self.child.kill()?; + self.child.wait()?; + break; + } + None => std::thread::sleep(Duration::from_millis(250)), + } + } + self.stopped = true; + fs::remove_dir_all(&self.dir)?; + Ok(()) + } +} + +impl Drop for TemporaryDatabase { + fn drop(&mut self) { + if !self.stopped { + if let Err(e) = self.stop() { + tracing::warn!(error = %e, pid = self.child.id(), "temporary database left behind"); + } + } + } +} + +fn copy_tree(from: &Path, to: &Path) -> Result { + let mut bytes = 0; + fs::create_dir_all(to)?; + for entry in fs::read_dir(from)? { + let entry = entry?; + let md = fs::symlink_metadata(entry.path())?; + if md.file_type().is_symlink() { + return Err(Error::Invalid(format!("{} is a link; the database folder is not copied.", entry.path().display()))); + } + let target = to.join(entry.file_name()); + if md.is_dir() { + bytes += copy_tree(&entry.path(), &target)?; + } else { + fs::copy(entry.path(), &target)?; + bytes += md.len(); + } + } + Ok(bytes) +} + +fn dir_size(dir: &Path) -> u64 { + fs::read_dir(dir).map(|rd| rd.flatten().map(|e| { + let p = e.path(); + if p.is_dir() { dir_size(&p) } else { e.metadata().map(|m| m.len()).unwrap_or(0) } + }).sum()).unwrap_or(0) +} + +fn start_temporary(old: &Path, new: &Path, work: &Path, progress: &dyn Fn(&str)) -> Result<(TemporaryDatabase, Db)> { + let source = old.join("mysql/data"); + if !source.join("mysql").is_dir() || !source.join("ibdata1").is_file() { + return Err(Error::Invalid("The old server's database folder is incomplete.".into())); + } + let dir = work.join("old-database"); + let _ = fs::remove_dir_all(&dir); + fsx::require_space(work, dir_size(&source))?; + progress("Copying the old server's database files"); + let data = dir.join("data"); + copy_tree(&source, &data)?; + let (root_password, _) = crate::db::credentials(old)?; + let bin = new.join("mysql/bin"); + let port = crate::install::free_port()?; + let child = Command::new(bin.join("mysqld.exe")) + .arg("--no-defaults") + .arg(format!("--basedir={}", new.join("mysql").display())) + .arg(format!("--datadir={}", data.display())) + .arg(format!("--port={port}")) + .args(["--bind-address=127.0.0.1", "--mysqlx=0", "--skip-log-bin", "--character-set-server=utf8mb4", + "--collation-server=utf8mb4_unicode_ci", "--max_allowed_packet=128M"]) + .arg(format!("--log-error={}", dir.join("mysql-error.log").display())) + .arg(format!("--pid-file={}", dir.join("mysql.pid").display())) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .creation_flags_hidden() + .spawn()?; + let db = Db::with_tools(bin, port, "root", &root_password, crate::realms::Mode::Coa); + let mut temp = TemporaryDatabase { child, dir, db: Some(db.clone()), stopped: false }; + progress("Opening the copy of the old database"); + let deadline = Instant::now() + Duration::from_secs(300); + while !db.ping() { + if let Ok(Some(status)) = temp.child.try_wait() { + let log = fs::read_to_string(temp.dir.join("mysql-error.log")).unwrap_or_default(); + let tail: Vec<&str> = log.lines().rev().take(8).collect(); + return Err(Error::Invalid(format!( + "The copy of the old database did not open ({status}). {}", + tail.into_iter().rev().collect::>().join(" ") + ))); + } + if Instant::now() > deadline { + return Err(Error::Invalid("The copy of the old database did not open within 5 minutes.".into())); + } + std::thread::sleep(Duration::from_millis(300)); + } + Ok((temp, db)) +} + +trait HiddenWindow { + fn creation_flags_hidden(&mut self) -> &mut Self; +} + +impl HiddenWindow for Command { + fn creation_flags_hidden(&mut self) -> &mut Self { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + self.creation_flags(0x0800_0000); + } + self + } +} + +/// Settings the owner may have changed that the new core can take as they are: the module configs +/// (`Core/configs/modules/*.conf`; the shipped `.dist` files belong to the new core) and the realm profiles. +/// `worldserver.conf` and `authserver.conf` are rendered by the launcher from the new server's own +/// templates at every start, so they are not copied. +fn settings_files(old: &Path) -> Vec { + crate::backup::config_files(old) + .into_iter() + .filter(|f| { + (f.starts_with("Core/configs/modules/") && f.ends_with(".conf") && !f.ends_with("/mod_ascension_compat.conf")) + || f.starts_with("Settings/realm-profile") + }) + .collect() +} + +/// Copy the old server's data into `new`, a server just installed and not yet started by anyone. +pub fn copy_data(old: &Path, new: &Path, progress: &dyn Fn(&str)) -> Result { + if !is_classic(old) { + return Err(Error::Invalid("Only a server with the classic core is copied.".into())); + } + if fsx::canonicalize_lenient(old)? == fsx::canonicalize_lenient(new)? { + return Err(Error::Invalid("The new server must be in another folder.".into())); + } + let work = crate::registry::metadata_dir_for(new)?.join("import"); + let _ = fs::remove_dir_all(&work); + fs::create_dir_all(&work)?; + + // 1. Read the old databases. + let observed = crate::process::observe(old, &crate::layout::read_ports(old)); + let running = observed.mysql.state == ServiceState::Running + && Db::from_repack(old, Account::Admin).map(|db| db.ping()).unwrap_or(false); + let mut temporary: Option = None; + let (source, read_from) = if running { + (Db::from_repack(old, Account::Admin)?.for_realm(crate::realms::Mode::Coa), "running database") + } else { + let (temp, db) = start_temporary(old, new, &work, progress)?; + temporary = Some(temp); + (db, "copy of the stopped database") + }; + let schemas = game_schemas(&source)?; + let accounts = count(&source, "SELECT COUNT(*) FROM acore_auth.account;"); + let characters = count(&source, "SELECT COUNT(*) FROM acore_characters.characters;"); + let mut dumps = Vec::new(); + for schema in &schemas { + progress(&format!("Copying {schema}")); + let file = work.join(format!("{schema}.sql.zst")); + source.dump_to(schema, &file)?; + dumps.push((schema.clone(), file)); + } + // The private copy of the old data files is not needed any more; its database must be gone before the new + // server's own database starts. + if let Some(mut temp) = temporary.take() { + progress("Closing the copy of the old database"); + temp.stop()?; + } + + // 2. Put them into the new server, on ports nobody uses, so a running old server is not disturbed. + crate::install::with_scratch_ports(new, || { + crate::backup::with_database(new, |db| { + let db = db.clone().for_realm(crate::realms::Mode::Coa); + for (schema, file) in &dumps { + progress(&format!("Importing {schema}")); + db.query(&format!( + "DROP DATABASE IF EXISTS `{schema}`; CREATE DATABASE `{schema}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + ))?; + db.import_from(schema, file)?; + } + let (a, c) = ( + count(&db, "SELECT COUNT(*) FROM acore_auth.account;"), + count(&db, "SELECT COUNT(*) FROM acore_characters.characters;"), + ); + if (a, c) != (accounts, characters) { + return Err(Error::Invalid(format!( + "The copy is incomplete: {a} of {accounts} accounts and {c} of {characters} characters arrived." + ))); + } + Ok(()) + }) + })?; + + // 3. Settings: module configs and realm profiles; ports and console login as the old server had them + // (the console account lives in the copied accounts database). + progress("Copying settings"); + let files = settings_files(old); + for rel in &files { + let from = fsx::ensure_within(old, &fsx::safe_join(old, rel)?)?; + let to = fsx::ensure_within(new, &fsx::safe_join(new, rel)?)?; + fsx::atomic_write(&to, &fs::read(from)?)?; + } + let old_repack: serde_json::Value = fsx::read_json(&old.join("Settings/repack.json"))?; + let mut repack: serde_json::Value = fsx::read_json(&new.join("Settings/repack.json"))?; + if let (Some(dst), Some(src)) = (repack.as_object_mut(), old_repack.as_object()) { + for (k, v) in src { + dst.insert(k.clone(), v.clone()); + } + } + fsx::atomic_write_json(&new.join("Settings/repack.json"), &repack)?; + let _ = fs::remove_dir_all(&work); + Ok(CopyReport { schemas, accounts, characters, config_files: files, read_from: read_from.into() }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_owner_settings_are_copied() { + let dir = tempfile::tempdir().unwrap(); + let old = dir.path(); + for rel in [ + "Core/configs/worldserver.conf", + "Core/configs/worldserver.conf.dist", + "Core/configs/modules/mod_x.conf", + "Core/configs/modules/mod_x.conf.dist", + "Core/configs/modules/mod_ascension_compat.conf", + "Settings/repack.json", + "Settings/worldserver.conf.template", + "Settings/realm-profile.json", + ] { + let p = old.join(rel); + fs::create_dir_all(p.parent().unwrap()).unwrap(); + fs::write(p, b"x").unwrap(); + } + let mut got = settings_files(old); + got.sort(); + assert_eq!(got, vec![ + "Core/configs/modules/mod_x.conf".to_string(), + "Settings/realm-profile.json".to_string(), + ]); + } + + #[test] + fn a_folder_without_a_core_is_not_classic() { + let dir = tempfile::tempdir().unwrap(); + assert!(!is_classic(dir.path())); + } +} diff --git a/crates/coa-core/src/modules.rs b/crates/coa-core/src/modules.rs index 9246ca6..c6e6b92 100644 --- a/crates/coa-core/src/modules.rs +++ b/crates/coa-core/src/modules.rs @@ -711,6 +711,17 @@ pub fn ensure_bot_exclusivity(root: &Path) -> Result<()> { Ok(()) } +/// Dungeon Clear is part of SQUID's Playerbots (the plugin core builds it into the SQUID DLL): it goes on +/// with SQUID, and off with it, wherever its config exists. +fn switch_on_dungeon_clear_with_squid(cat: &[Entry], root: &Path, meta: &Path) { + if let Some(dc) = cat.iter().find(|entry| entry.id == "dungeon-clear") { + let name = conf_name(root, dc); + if dir(root).join(&name).exists() || dir(root).join(format!("{name}.dist")).exists() { + let _ = set_enabled_in(cat, root, meta, "dungeon-clear", true); + } + } +} + fn set_enabled_in(cat: &[Entry], root: &Path, meta: &Path, id: &str, on: bool) -> Result<()> { match entry_in(cat, id) { Ok(e) => { @@ -770,6 +781,7 @@ fn set_enabled_in(cat: &[Entry], root: &Path, meta: &Path, id: &str, on: bool) - } if id == "playerbots" && on { let _ = crate::plugins::record_squid_migrations_applied(root); + switch_on_dungeon_clear_with_squid(cat, root, meta); } tracing::info!(module = id, on, "module switched"); @@ -806,6 +818,9 @@ fn set_enabled_in(cat: &[Entry], root: &Path, meta: &Path, id: &str, on: bool) - let _ = set_enabled_in(cat, root, meta, "dungeon-clear", false); } } + if id == "playerbots" && on { + switch_on_dungeon_clear_with_squid(cat, root, meta); + } return Ok(()); } } @@ -1255,7 +1270,7 @@ mod tests { } #[test] - fn dungeon_clear_requires_squid_and_stays_off_after_squid_is_reenabled() { + fn dungeon_clear_requires_squid_and_follows_it() { let d = tempfile::tempdir().unwrap(); let root = d.path(); fs::create_dir_all(dir(root)).unwrap(); @@ -1266,13 +1281,13 @@ mod tests { let cat = catalog(); assert!(set_enabled_in(&cat, root, root, "dungeon-clear", true).is_err()); assert!(!list_in(&cat, root).iter().find(|entry| entry.id == "dungeon-clear").unwrap().switchable); + // part of SQUID: on with it, off with it, on again with it set_enabled_in(&cat, root, root, "playerbots", true).unwrap(); - set_enabled_in(&cat, root, root, "dungeon-clear", true).unwrap(); assert!(read(&dungeon).unwrap().get("DungeonClear.Enable").map(truthy).unwrap()); set_enabled_in(&cat, root, root, "playerbots", false).unwrap(); assert!(!read(&dungeon).unwrap().get("DungeonClear.Enable").map(truthy).unwrap()); set_enabled_in(&cat, root, root, "playerbots", true).unwrap(); - assert!(!read(&dungeon).unwrap().get("DungeonClear.Enable").map(truthy).unwrap()); + assert!(read(&dungeon).unwrap().get("DungeonClear.Enable").map(truthy).unwrap()); } #[test] diff --git a/crates/coa-core/src/rebuild.rs b/crates/coa-core/src/rebuild.rs index 9c069d7..21577a7 100644 --- a/crates/coa-core/src/rebuild.rs +++ b/crates/coa-core/src/rebuild.rs @@ -259,9 +259,32 @@ fn split_repo(repo: &str) -> std::result::Result<(&str, &str), String> { .ok_or_else(|| format!("invalid repository '{repo}'")) } +/// Mark a server that starts with the bare core: the modules the core recommends are not installed on it +/// (the owner adds modules from the Modules page). Taken into account the first time a plugin-host core +/// arrives; after that every recommended module counts as dealt with. +pub fn decline_recommended(realm_root: &Path) -> Result<()> { + let body = serde_json::json!({ "schema": 1, "handled": load_handled(realm_root), "declineAll": true }); + fs::create_dir_all(plugins::meta_dir(realm_root))?; + crate::fsx::atomic_write(&handled_path(realm_root), serde_json::to_string_pretty(&body)?.as_bytes()) +} + +fn declined_all(realm_root: &Path) -> bool { + fs::read(handled_path(realm_root)).ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .and_then(|v| v.get("declineAll")?.as_bool()) + .unwrap_or(false) +} + fn install_recommended(realm_root: &Path, progress: &dyn Fn(&str, &str)) -> Vec { let Some(rec) = recommended(realm_root) else { return Vec::new() }; let mut handled = load_handled(realm_root); + if declined_all(realm_root) { + for m in &rec.modules { + handled.entry(m.id.clone()).or_insert_with(|| "not installed: new server with the bare core".into()); + } + save_handled(realm_root, &handled); // drops the mark: it applies to this first core only + return Vec::new(); + } let mut out = Vec::new(); for m in &rec.modules { let registry = plugins::load_registry(realm_root).unwrap_or_default(); @@ -455,6 +478,25 @@ mod tests { assert!(!handled.contains_key("companions")); } + #[test] + fn a_new_server_with_the_bare_core_installs_no_recommended_module() { + let dir = tempfile::tempdir().unwrap(); + let server = dir.path(); + fs::create_dir_all(server.join("Core/configs/modules")).unwrap(); + fs::create_dir_all(server.join("Core/plugins")).unwrap(); + fs::write(server.join("Core/worldserver.exe"), image("worldserver", &"c".repeat(64))).unwrap(); + decline_recommended(server).unwrap(); + // the mark is written before the plugin-host core (and its recommended.json) arrives + fs::write(server.join("Core/plugins/recommended.json"), r#"{"schema":1,"modules":[ + {"id":"companions","repo":"not-a-repo","commit":"0000000000000000000000000000000000000000"}]}"#).unwrap(); + let out = rebuild_for_current_core(server, &|_, _| panic!("nothing is built")).unwrap(); + assert!(out.is_empty(), "{out:?}"); + assert!(load_handled(server)["companions"].contains("bare core")); + assert!(!declined_all(server), "the mark applies to the first plugin-host core only"); + // a later core change does not bring it back either + assert!(rebuild_for_current_core(server, &|_, _| panic!("nothing is built")).unwrap().is_empty()); + } + /// Real run: COA_TEST_RECOMMENDED_REALM is a plugin-host realm with Core/plugins/recommended.json (or /// plugins/recommended.json); the modules are downloaded from GitHub, built and switched on. #[test] diff --git a/package-lock.json b/package-lock.json index 2f93a6d..c03e5ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.9.9", + "version": "0.9.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.9.9", + "version": "0.9.10", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index 643484c..a0d6431 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.9.9", + "version": "0.9.10", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 99f9f18..5224e01 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1062,12 +1062,25 @@ async fn install_new( dest: String, package: Option, game_data: Option, + copy_from: Option, ) -> std::result::Result { + let copy_from = copy_from.filter(|p| !p.trim().is_empty()).map(PathBuf::from); if platform::flavor() == Flavor::Docker { + if copy_from.is_some() { + return Err(Error::Invalid("A modular copy is made of a Windows server only.".into()).into()); + } // The plugin host is Windows-only for now: a Linux server keeps the classic core. return docker_install::install(app, state, dest, package, game_data) .await - .map(|summary| InstalledServer { summary, preview_core_error: None, modules_off: Vec::new() }); + .map(|summary| InstalledServer { summary, preview_core_error: None, modules_off: Vec::new(), copied: None }); + } + if let Some(old) = ©_from { + if !coa_core::modular_copy::is_classic(old) { + return Err(Error::Invalid("This server already has the modular core; add it as it is.".into()).into()); + } + } + if std::fs::read_dir(&dest).is_ok_and(|mut d| d.next().is_some()) { + return Err(Error::Invalid("Choose a new or empty folder for the server: this one already has files.".into()).into()); } let cancel = Cancel::default(); *state @@ -1077,14 +1090,15 @@ async fn install_new( let registry = Registry::at(data_dir().join("installs.json")); let dest_path = PathBuf::from(dest); // A package the owner picked by hand is installed as it is; otherwise the new server goes straight on to the - // preview core, so it starts out with modules as plugins instead of the classic core with them built in. - let to_preview = package.as_deref().map_or(true, |p| p.trim().is_empty()); + // preview core. A new server starts with the bare core (modules are added later from the Modules page); a + // modular copy of a classic server gets the modules its old server had switched on. + let to_preview = package.as_deref().map_or(true, |p| p.trim().is_empty()) || copy_from.is_some(); let result = tauri::async_runtime::spawn_blocking(move || { let source = installation_source(package)?; let done = install::install_base( &install::Params { source, - dest: dest_path, + dest: dest_path.clone(), trusted_key: coa_core::signing::EMBEDDED_PUBLIC_KEY, registry: ®istry, cancel, @@ -1093,27 +1107,63 @@ async fn install_new( let _ = app.emit("install-progress", step); }, )?; + let root = PathBuf::from(&done.path); + let copied = match ©_from { + Some(old) => { + let report = coa_core::modular_copy::copy_data(old, &root, &|what| { + let _ = app.emit( + "install-progress", + serde_json::json!({ "step": "Copying the old server's data", "percent": 100, "detail": what }), + ); + }); + match report { + Ok(r) => Some(r), + Err(e) => { + // Nothing of the old server was changed; take away the half-made copy (this call created it). + let _ = registry.unregister(&done.id); + let _ = coa_core::driver::run(&root, Verb::StopAll); + let _ = std::fs::remove_dir_all(&root); + if let Ok(meta) = metadata_dir_for(&root) { + let _ = std::fs::remove_dir_all(meta); + } + return Err(Error::Invalid(format!("The old server's data could not be copied: {e}"))); + } + } + } + None => { + if to_preview { + coa_core::rebuild::decline_recommended(&root)?; + } + None + } + }; let (preview_core_error, modules_off) = match to_preview { false => (None, Vec::new()), - true => match install_preview_core(&app, &PathBuf::from(&done.path)) { + true => match install_preview_core(&app, &root) { Ok(off) => (None, off), Err(e) => (Some(e), Vec::new()), }, }; - Ok::<_, Error>((done, preview_core_error, modules_off)) + Ok::<_, Error>((done, preview_core_error, modules_off, copied)) }) .await; if let Ok(mut c) = state.install_cancel.lock() { *c = None; } - let (done, preview_core_error, modules_off) = result.map_err(|e| Error::Invalid(e.to_string()))??; + let (done, preview_core_error, modules_off, copied) = result.map_err(|e| Error::Invalid(e.to_string()))??; Ok(InstalledServer { summary: summary(done.id, PathBuf::from(done.path)), preview_core_error, modules_off, + copied, }) } +#[tauri::command] +fn is_classic_server(path: String) -> bool { + coa_core::modular_copy::is_classic(Path::new(&path)) +} + #[derive(Serialize)] struct InstalledServer { #[serde(flatten)] @@ -1122,6 +1172,8 @@ struct InstalledServer { preview_core_error: Option, /// Modules that did not build for the preview core and were switched off, with the reason. modules_off: Vec, + /// For a modular copy of a classic server: what was copied. + copied: Option, } /// Put the preview core on a server that was just installed from the stable image: the same signed update every @@ -1160,7 +1212,8 @@ fn install_preview_core(app: &AppHandle, root: &Path) -> std::result::Result(null); const [view, setView] = useState(initialView); const [activeId, setActiveId] = useState(null); + // A classic server the owner chose: the install screen makes a modular copy of it next to it. + const [copyFrom, setCopyFrom] = useState(null); const refresh = useCallback(async () => { const list = await api.list().catch(() => []); @@ -56,8 +58,13 @@ function Main() { return ( setView(servers.length > 0 ? "add" : "welcome")} + copyFrom={copyFrom ?? undefined} + onCancel={() => { + setView(copyFrom ? "import" : servers.length > 0 ? "add" : "welcome"); + setCopyFrom(null); + }} onDone={async (s) => { + setCopyFrom(null); await refresh(); setActiveId(s.id); setView("welcome"); @@ -76,6 +83,10 @@ function Main() { setActiveId(s.id); setView("welcome"); }} + onCopy={(path) => { + setCopyFrom(path); + setView("install"); + }} /> ); } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 349fa80..dc96805 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -267,8 +267,16 @@ const en = { "install.step.unpacking": "Unpacking", "install.step.database": "Preparing database", "install.step.finishing": "Finishing", - "install.step.modularCore": "Installing the modular core and building modules", - "install.modularCoreHint": "This step compiles the modules on this computer and can take several minutes.", + "install.step.modularCore": "Installing the modular core", + "install.modularCoreHint": "Modules the old server had switched on are compiled on this computer; this can take several minutes.", + "install.step.copying": "Copying the old server's data", + "install.copy.title": "Modular copy of your server", + "install.copy.subtitle": "Your server has the classic core. A new server with the modular core is installed next to it, and accounts, characters, the world and module settings are copied into it. The old server is not changed; you can keep using it.", + "install.copy.from": "Copy from:", + "install.copy.done": "Copied {accounts} accounts, {characters} characters and {settings} settings files.", + "import.classic.title": "This server has the classic core", + "import.classic.text": "Its modules are compiled into the core. The Modules Preview does not change it: it installs a modular server next to it and copies the data into it. Run one of the two at a time, they use the same ports.", + "import.classic.copy": "Create a modular copy", "install.classicCoreTitle": "The server stays on the classic core", "install.classicCoreText": "The server is installed and works, but the modular core did not go on, so its modules are built into the core. You can try again from Updates.", "install.modulesOffTitle": "Some modules were switched off", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 9db3e4c..edd2755 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -251,8 +251,16 @@ const ru: Partial> = { "install.step.unpacking": "Распаковка", "install.step.database": "Подготовка базы данных", "install.step.finishing": "Завершение", - "install.step.modularCore": "Установка модульного ядра и сборка модулей", - "install.modularCoreHint": "На этом шаге модули компилируются на вашем компьютере, это может занять несколько минут.", + "install.step.modularCore": "Установка модульного ядра", + "install.modularCoreHint": "Модули, которые были включены на старом сервере, компилируются на вашем компьютере; это может занять несколько минут.", + "install.step.copying": "Копирование данных старого сервера", + "install.copy.title": "Модульная копия вашего сервера", + "install.copy.subtitle": "У вашего сервера классическое ядро. Рядом ставится новый сервер с модульным ядром, в него копируются аккаунты, персонажи, мир и настройки модулей. Старый сервер не меняется, им можно продолжать пользоваться.", + "install.copy.from": "Копировать из:", + "install.copy.done": "Скопировано аккаунтов: {accounts}, персонажей: {characters}, файлов настроек: {settings}.", + "import.classic.title": "У этого сервера классическое ядро", + "import.classic.text": "Модули вкомпилированы в ядро. Превью-менеджер его не меняет: он ставит рядом модульный сервер и копирует в него данные. Запускайте один из двух: порты у них одинаковые.", + "import.classic.copy": "Создать модульную копию", "install.classicCoreTitle": "Сервер остался на классическом ядре", "install.classicCoreText": "Сервер установлен и работает, но модульное ядро не встало, поэтому модули встроены в ядро. Можно повторить из раздела «Обновления».", "install.modulesOffTitle": "Часть модулей выключена", diff --git a/src/lib/api.ts b/src/lib/api.ts index dda39e0..72f524c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -470,6 +470,14 @@ export interface AccountInfo { characters: number; } +export interface CopyReport { + schemas: string[]; + accounts: number; + characters: number; + config_files: string[]; + read_from: string; +} + export interface InstallStep { step: string; percent: number; @@ -798,7 +806,9 @@ export const api = { accountSetAccess: (id: string, name: string, level: number) => invoke("account_set_access", { id, name, level }), accountRename: (id: string, name: string, newName: string, password: string) => invoke("account_rename", { id, name, newName, password }), accountDelete: (id: string, name: string) => invoke("account_delete", { id, name }), - installNew: (dest: string, pkg?: string, gameData?: string) => invoke("install_new", { dest, package: pkg ?? null, gameData: gameData ?? null }), + installNew: (dest: string, pkg?: string, gameData?: string, copyFrom?: string) => + invoke("install_new", { dest, package: pkg ?? null, gameData: gameData ?? null, copyFrom: copyFrom ?? null }), + isClassicServer: (path: string) => invoke("is_classic_server", { path }), cancelInstall: () => invoke("cancel_install"), createAccount: (id: string, username: string, password: string, administrator: boolean) => invoke("create_account", { id, username, password, administrator }), diff --git a/src/screens/ImportServer.tsx b/src/screens/ImportServer.tsx index 2267fba..1beefff 100644 --- a/src/screens/ImportServer.tsx +++ b/src/screens/ImportServer.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { open } from "@tauri-apps/plugin-dialog"; import { AlertTriangle, Check, Minus } from "lucide-react"; import { api, asUiError, type Classification, type ScanReport, type ServerSummary, type UiError } from "@/lib/api"; @@ -17,10 +17,17 @@ export function ImportServer(props: { canCancel: boolean; onCancel: () => void; onAdded: (s: ServerSummary) => void | Promise; + onCopy: (path: string) => void; }) { const t = useT(); const human = useHuman(); const [report, setReport] = useState(null); + // A classic core (modules compiled in) is not added as it is: the Modules Preview makes a modular copy next to it. + const [classic, setClassic] = useState(false); + useEffect(() => { + setClassic(false); + if (report && report.classification !== "incompatible") void api.isClassicServer(report.path).then(setClassic).catch(() => setClassic(false)); + }, [report]); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -149,16 +156,28 @@ export function ImportServer(props: {

))} -
-

{t("import.noChanges")}

- -
+ {classic ? ( +
+

{t("import.classic.title")}

+

{t("import.classic.text")}

+
+ +
+
+ ) : ( +
+

{t("import.noChanges")}

+ +
+ )} )} diff --git a/src/screens/InstallServer.tsx b/src/screens/InstallServer.tsx index c9606aa..74a8bd9 100644 --- a/src/screens/InstallServer.tsx +++ b/src/screens/InstallServer.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import { open } from "@tauri-apps/plugin-dialog"; import { Check, ChevronDown, ChevronRight, Loader2 } from "lucide-react"; -import { api, asUiError, type InstallEnvironment, type InstallRequirements, type InstallStep, type Preflight, type ServerSummary, type UiError } from "@/lib/api"; +import { api, asUiError, type CopyReport, type InstallEnvironment, type InstallRequirements, type InstallStep, type Preflight, type ServerSummary, type UiError } from "@/lib/api"; import { formatBytes } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -17,14 +17,17 @@ const STEP_KEYS: Record = { "Preparing database": "install.step.database", Finishing: "install.step.finishing", "Installing the modular core": "install.step.modularCore", + "Copying the old server's data": "install.step.copying", }; type Phase = "choose" | "installing" | "done"; -export function InstallServer(props: { canCancel: boolean; onCancel: () => void; onDone: (s: ServerSummary) => void | Promise }) { +export function InstallServer(props: { canCancel: boolean; copyFrom?: string; onCancel: () => void; onDone: (s: ServerSummary) => void | Promise }) { const t = useT(); const human = useHuman(); - const [dest, setDest] = useState("C:\\Games\\CoA Server"); + const copyFrom = props.copyFrom; + const [dest, setDest] = useState(copyFrom ? copyFrom.replace(/[\\/]+$/, "") + " (modular)" : "C:\\Games\\CoA Server"); + const [copied, setCopied] = useState(null); const [pre, setPre] = useState(null); const [req, setReq] = useState(null); const [phase, setPhase] = useState("choose"); @@ -90,8 +93,9 @@ export function InstallServer(props: { canCancel: boolean; onCancel: () => void; setPhase("installing"); setStep({ step: "Preparing your server", percent: 0, detail: null }); try { - const { preview_core_error, modules_off, ...s } = await api.installNew(dest, pkg.trim() || undefined, docker ? gameData.trim() : undefined); + const { preview_core_error, modules_off, copied, ...s } = await api.installNew(dest, copyFrom ? undefined : pkg.trim() || undefined, docker ? gameData.trim() : undefined, copyFrom); setInstalled(s); + setCopied(copied); setClassicCore(preview_core_error); setModulesOff(modules_off); setPhase("done"); @@ -117,7 +121,7 @@ export function InstallServer(props: { canCancel: boolean; onCancel: () => void;
{step.detail &&

{step.detail}

} - {step.step === "Installing the modular core" &&

{t("install.modularCoreHint")}

} + {step.step === "Installing the modular core" && copyFrom &&

{t("install.modularCoreHint")}

} {step.step === "Downloading server" && ( )} + {m.id === "playerbots" && m.installed && dungeonClear && ( + + )} {canRemove && (