diff --git a/crates/coa-core/src/multiworld.rs b/crates/coa-core/src/multiworld.rs index a3cf327..1b9001b 100644 --- a/crates/coa-core/src/multiworld.rs +++ b/crates/coa-core/src/multiworld.rs @@ -1,522 +1,1148 @@ //! A second supervised world with isolated configs, logs and ports; auth and MySQL remain shared. + use crate::{ + driver::{self, Verb}, + fsx, + process::{self, ServiceState}, + realms::{self, Mode, RealmState}, + Error, Result, + }; + use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, + }; + + /// A world that runs next to the primary one. Each has its own folder, configs, logs and ports; auth and MySQL stay shared. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum Tree { + /// Wildcard next to Conquest of Azeroth (or Conquest of Azeroth next to Wildcard when Wildcard is the primary). + Second, + /// Warcraft Reborn next to Conquest of Azeroth. + Reborn, + } + + impl Tree { + pub const ALL: [Tree; 2] = [Tree::Second, Tree::Reborn]; + + fn dir(self) -> &'static str { + match self { + Tree::Second => ".realms/secondary", + Tree::Reborn => ".realms/reborn", + } + } + + fn label(self) -> &'static str { + match self { + Tree::Second => "second realm", + Tree::Reborn => "Warcraft Reborn realm", + } + } + + fn mode(self, active: Mode) -> Mode { + match self { + Tree::Second if active == Mode::Coa => Mode::Wildcard, + Tree::Second => Mode::Coa, + Tree::Reborn => Mode::Reborn, + } + } + + fn enabled(self, s: &RealmState) -> bool { + match self { + Tree::Second => s.simultaneous, + Tree::Reborn => s.with_reborn, + } + } + + fn ports(self, s: &RealmState) -> (Option, Option) { + match self { + Tree::Second => (s.secondary_world_port, s.secondary_ra_port), + Tree::Reborn => (s.reborn_world_port, s.reborn_ra_port), + } + } + } + + pub fn tree_root(main: &Path, tree: Tree) -> PathBuf { + main.join(tree.dir()) + } + + /// The folder of the second realm (Wildcard next to Conquest of Azeroth). + pub fn root(main: &Path) -> PathBuf { + tree_root(main, Tree::Second) + } + + pub fn tree_running(main: &Path, tree: Tree) -> bool { + let second = tree_root(main, tree); + if !second.exists() { + return false; + } + let status = process::observe(&second, &crate::layout::read_ports(&second)); + status.world.state != ServiceState::Stopped + || ["supervisor", "relay"] + .iter() + .any(|n| process::read_state_record(&second, n).is_some_and(|p| process::is_alive(&p))) + } + + /// The folder whose world server runs `mode`: the main folder, or the folder of the extra world that runs that realm. + /// Its settings hold the console port that belongs to that world. + pub fn world_root(main: &Path, mode: Mode) -> PathBuf { + let Ok(s) = realms::state(main) else { + return main.to_path_buf(); + }; + if mode == s.active { + return main.to_path_buf(); + } + Tree::ALL + .into_iter() + .find(|t| t.enabled(&s) && t.mode(s.active) == mode) + .map_or_else(|| main.to_path_buf(), |t| tree_root(main, t)) + } + + +#[derive(Debug, Clone)] + +pub struct BotOperationTarget { + + pub world_root: PathBuf, + + pub chars_db: &'static str, + + pub ra_port: u16, + + pub running: bool, + + pub realm: Mode, + +} + + + +pub fn resolve_bot_operation_target(main: &Path, realm: Mode) -> Result { + + let state = realms::state(main)?; + + let chars_db = realm.schema("characters")?; + + + + if realm == state.active { + + let ports = crate::layout::read_ports(main); + + let ra_port = ports.ra; + + let status = process::observe(main, &ports); + + let running = status.world.state != ServiceState::Stopped; + + return Ok(BotOperationTarget { + + world_root: main.to_path_buf(), + + chars_db, + + ra_port, + + running, + + realm, + + }); + + } + + + + let tree = Tree::ALL + + .into_iter() + + .find(|t| t.enabled(&state) && t.mode(state.active) == realm) + + .ok_or_else(|| Error::Invalid(format!("Realm {} is not enabled in multiworld configuration.", realm.label())))?; + + + + let root = tree_root(main, tree); + + if !root.exists() { + + return Err(Error::Invalid(format!("Multiworld directory for {} does not exist: {}", realm.label(), root.display()))); + + } + + + + let ports = crate::layout::read_ports(&root); + + let (_, default_ra) = tree.ports(&state); + + let ra_port = if ports.ra != 0 { ports.ra } else { default_ra.unwrap_or(3444) }; + + let status = process::observe(&root, &ports); + + let running = status.world.state != ServiceState::Stopped; + + + + Ok(BotOperationTarget { + + world_root: root, + + chars_db, + + ra_port, + + running, + + realm, + + }) + +} + + + /// Any of the extra worlds is running or still stopping. + pub fn is_running(main: &Path) -> bool { + Tree::ALL.into_iter().any(|t| tree_running(main, t)) + } + + fn copy_tree(from: &Path, to: &Path) -> Result<()> { + fs::create_dir_all(to)?; + for entry in fs::read_dir(from)? { + let entry = entry?; + if entry.file_name() == "Logs" + || entry.file_name() == "reports" + || entry.file_name() == "__pycache__" + { + continue; + } + if entry.file_type()?.is_symlink() { + return Err(Error::Invalid( + "A linked runtime file cannot be used for a second realm.".into(), + )); + } + let target = fsx::ensure_within(to, &to.join(entry.file_name()))?; + if entry.file_type()?.is_dir() { + copy_tree(&entry.path(), &target)?; + } else if !target.is_file() + || fsx::sha256_file(&entry.path())? != fsx::sha256_file(&target)? + { + fs::copy(entry.path(), target)?; + } + } + Ok(()) + } + + /// Start Wildcard together with Conquest of Azeroth. + pub fn set_enabled(main: &Path, enabled: bool) -> Result { + set_tree(main, Tree::Second, enabled) + } + + /// Start Warcraft Reborn together with Conquest of Azeroth. + pub fn set_reborn_enabled(main: &Path, enabled: bool) -> Result { + set_tree(main, Tree::Reborn, enabled) + } + + fn set_tree(main: &Path, tree: Tree, enabled: bool) -> Result { + if enabled && !cfg!(windows) { + return Err(Error::Invalid( + "Simultaneous realms currently require Windows.".into(), + )); + } + if enabled { + let active = realms::state(main)?.active; + let view = realms::view(main)?; + match tree { + Tree::Second => { + if active == Mode::Reborn { + return Err(Error::Invalid( + "Wildcard starts together with Conquest of Azeroth. Select Conquest of Azeroth first." + .into(), + )); + } + if !view.supported { + return Err(Error::Invalid( + "Update the server to a build with Wildcard support first.".into(), + )); + } + } + Tree::Reborn => { + if active != Mode::Coa { + return Err(Error::Invalid( + "Warcraft Reborn starts together with Conquest of Azeroth. Select Conquest of Azeroth first." + .into(), + )); + } + if !view.reborn_supported { + return Err(Error::Invalid( + "Update the server to a build with Warcraft Reborn support first.".into(), + )); + } + } + } + secondary_launcher(&fs::read_to_string(main.join("Scripts/manage.py"))?)?; + } + let status = process::observe(main, &crate::layout::read_ports(main)); + if status.world.state != ServiceState::Stopped + || status.auth.state != ServiceState::Stopped + || is_running(main) + { + return Err(Error::Invalid( + "Stop all realms before changing which realms start together.".into(), + )); + } + if enabled { + realms::ensure_created(main, tree.mode(realms::state(main)?.active))?; + } + let mut s = realms::state(main)?; + if enabled { + let p = crate::layout::read_ports(main); + let mut reserved = vec![p.mysql, p.auth, p.world, p.ra]; + // Ports already saved for any other extra world stay reserved, so two worlds never get the same one. + for other in Tree::ALL.into_iter().filter(|t| *t != tree) { + let (world, ra) = other.ports(&s); + reserved.extend(world.into_iter().chain(ra)); + } + let mut choose = |saved: Option, near: u16| -> Result { + if let Some(port) = saved.filter(|p| *p > 0 && !reserved.contains(p)) { + reserved.push(port); + return Ok(port); + } + let port = (near.saturating_add(1)..=u16::MAX) + .find(|p| { + !reserved.contains(p) && std::net::TcpListener::bind(("127.0.0.1", *p)).is_ok() + }) + .ok_or_else(|| Error::Invalid(format!("No free port for the {}.", tree.label())))?; + reserved.push(port); + Ok(port) + }; + let (world, ra) = tree.ports(&s); + let (world, ra) = (choose(world, p.world)?, choose(ra, p.ra)?); + match tree { + Tree::Second => { + s.secondary_world_port = Some(world); + s.secondary_ra_port = Some(ra); + } + Tree::Reborn => { + s.reborn_world_port = Some(world); + s.reborn_ra_port = Some(ra); + } + } + } + match tree { + Tree::Second => s.simultaneous = enabled, + Tree::Reborn => s.with_reborn = enabled, + } + fsx::atomic_write_json(&main.join("Settings/realm-profile.json"), &s)?; + realms::view(main) + } + + fn prepare(main: &Path, tree: Tree) -> Result { + if tree_running(main, tree) { + return Err(Error::Invalid(format!( + "The {} is still running; stop it before preparing its files.", + tree.label() + ))); + } + let s = realms::state(main)?; + let mode = tree.mode(s.active); + let second = fsx::ensure_within(main, &tree_root(main, tree))?; + for folder in ["Runtime", "BugReport", "mysql/bin"] { + copy_tree( + &main.join(folder), + &fsx::ensure_within(&second, &second.join(folder))?, + )?; + } + if main.join("Scripts/squid_playerbots.py").is_file() { + fs::create_dir_all(second.join("Scripts"))?; + fs::copy( + main.join("Scripts/squid_playerbots.py"), + second.join("Scripts/squid_playerbots.py"), + )?; + } + if main.join("Core/reference").is_dir() { + copy_tree( + &main.join("Core/reference"), + &fsx::ensure_within(&second, &second.join("Core/reference"))?, + )?; + } + if main.join("Core/plugins").is_dir() { + copy_tree( + &main.join("Core/plugins"), + &fsx::ensure_within(&second, &second.join("Core/plugins"))?, + )?; + } + for entry in fs::read_dir(main.join("Core"))? { + let entry = entry?; + if entry.file_type()?.is_file() + && (entry.file_name() == "worldserver.exe" + || entry.path().extension().is_some_and(|x| x == "dll")) + { + fs::create_dir_all(second.join("Core"))?; + fs::copy( + entry.path(), + fsx::ensure_within(&second, &second.join("Core").join(entry.file_name()))?, + )?; + } + } + let files: BTreeMap> = + fsx::read_json(&main.join(format!("Settings/realm-profiles/{}.json", mode.name())))?; + // A previous secondary run may have used the other realm's module files. + for path in crate::backup::config_files(&second) { + if path.starts_with("Core/configs/") + && path.ends_with(".conf") + && !files.contains_key(&path) + { + fs::remove_file(fsx::ensure_within( + &second, + &fsx::safe_join(&second, &path)?, + )?)?; + } + } + for (path, bytes) in files { + if !(path.starts_with("Core/configs/") && path.ends_with(".conf") + || path.starts_with("Settings/") && path.ends_with(".template")) + { + return Err(Error::Invalid( + "Invalid secondary realm configuration path.".into(), + )); + } + let text = String::from_utf8(bytes) + .map_err(|_| Error::Invalid("Invalid realm config encoding.".into()))?; + let shared = main.to_string_lossy().replace('\\', "/"); + let text = text + .replace("@DATA@", &format!("{shared}/Data")) + .replace("@ASCENSION_DBC@", &format!("{shared}/Data/dbc/Ascension")) + .replace("@MYSQL_EXE@", &format!("{shared}/mysql/bin/mysql.exe")); + let text = if path == "Settings/worldserver.conf.template" { + let main_conf = + crate::config::parser::ConfFile::parse_bytes(&fs::read(main.join(&path))?)?; + let mut other_conf = crate::config::parser::ConfFile::parse_bytes(text.as_bytes())?; + if let Some(bind) = main_conf.get("BindIP") { + other_conf.set("BindIP", bind, &[]); + } + other_conf.set("Ra.IP", "\"127.0.0.1\"", &[]); + other_conf.to_text() + } else if path == "Core/configs/modules/coa.conf" { + let mut conf = crate::config::parser::ConfFile::parse_bytes(text.as_bytes())?; + if crate::friends::bind_is_open(main) { + conf.set("CoA.AllowRemoteClients", "1", &[]); + } + conf.to_text() + } else { + text + }; + fsx::atomic_write( + &fsx::ensure_within(&second, &fsx::safe_join(&second, &path)?)?, + text.as_bytes(), + )?; + } + let mut cfg: serde_json::Value = fsx::read_json(&main.join("Settings/repack.json"))?; + let (world_port, ra_port) = tree.ports(&s); + cfg["worldPort"] = world_port + .ok_or_else(|| Error::Invalid(format!("The {} world port is missing.", tree.label())))? + .into(); + cfg["raPort"] = ra_port + .ok_or_else(|| Error::Invalid(format!("The {} console port is missing.", tree.label())))? + .into(); + fsx::atomic_write_json(&second.join("Settings/repack.json"), &cfg)?; + fsx::atomic_write( + &second.join("Settings/database.json"), + &fs::read(main.join("Settings/database.json"))?, + )?; + fsx::atomic_write_json( + &second.join("Settings/realm-profile.json"), + &RealmState { + active: mode, + wildcard_created: true, + reborn_created: true, + ..Default::default() + }, + )?; + let source = fs::read_to_string(main.join("Scripts/manage.py"))?; + let source = secondary_launcher(&source)?; + fsx::atomic_write(&second.join("Scripts/manage.py"), source.as_bytes())?; + Ok(second) + } + + fn secondary_launcher(source: &str) -> Result { + let marker = "def start_world(config, offset):\n start_mysql(config)"; + let source = source.replace("\r\n", "\n"); + if !source.contains(marker) { + return Err(Error::Invalid( + "The launcher does not support a shared database world. Update the server first." + .into(), + )); + } + Ok(source.replace( + marker, + "def start_world(config, offset):\n mysql(admin=\"ping\")", + )) + } + + fn start_tree(main: &Path, tree: Tree) -> Result<()> { + if tree_running(main, tree) { + let second = tree_root(main, tree); + if process::observe(&second, &crate::layout::read_ports(&second)) + .world + .state + == ServiceState::Running + { + return Ok(()); + } + return Err(Error::Invalid(format!( + "The {} is still starting or stopping.", + tree.label() + ))); + } + let second = prepare(main, tree)?; + let out = driver::run(&second, Verb::StartWorld)?; + if !out.ok { + return Err(Error::Invalid(format!( + "The {} did not start: {}", + tree.label(), + out.output + ))); + } + Ok(()) + } + + /// Start every extra world that is switched on. + pub fn start(main: &Path) -> Result<()> { + let s = realms::state(main)?; + for tree in Tree::ALL.into_iter().filter(|t| t.enabled(&s)) { + start_tree(main, tree)?; + } + Ok(()) + } + + fn stop_tree(main: &Path, tree: Tree) -> Result<()> { + let second = tree_root(main, tree); + if !second.join("Scripts/manage.py").exists() { + if tree_running(main, tree) { + return Err(Error::Invalid(format!( + "The running {}'s launcher is missing. The shared database was left running.", + tree.label() + ))); + } + return Ok(()); + } + // Its state has no auth/MySQL records: the shared database remains up until the primary world stops. + if tree_running(main, tree) { + let out = driver::run(&second, Verb::StopAll)?; + if !out.ok || tree_running(main, tree) { + return Err(Error::Invalid(format!( + "The {} could not stop; the database was left running: {}", + tree.label(), + out.output + ))); + } + } + Ok(()) + } + + /// Stop every extra world; one that fails does not keep the others running. + pub fn stop(main: &Path) -> Result<()> { + let mut first = None; + for tree in Tree::ALL { + if let Err(e) = stop_tree(main, tree) { + first.get_or_insert(e); + } + } + first.map_or(Ok(()), Err) + } + + #[cfg(test)] + mod tests { + use super::*; + #[test] + fn every_extra_world_has_its_own_folder_realm_and_ports() { + let mut s = RealmState::default(); + assert_eq!(Tree::Second.mode(Mode::Coa), Mode::Wildcard); + assert_eq!(Tree::Second.mode(Mode::Wildcard), Mode::Coa); + assert_eq!(Tree::Reborn.mode(Mode::Coa), Mode::Reborn); + assert_ne!(Tree::Second.dir(), Tree::Reborn.dir()); + s.secondary_world_port = Some(8086); + s.reborn_world_port = Some(8087); + assert_eq!(Tree::Second.ports(&s).0, Some(8086)); + assert_eq!(Tree::Reborn.ports(&s).0, Some(8087)); + assert!(!Tree::Second.enabled(&s) && !Tree::Reborn.enabled(&s)); + s.with_reborn = true; + assert!(!Tree::Second.enabled(&s) && Tree::Reborn.enabled(&s)); + } + + #[test] + fn a_realm_is_reached_through_the_folder_of_its_own_world() { + let t = tempfile::tempdir().unwrap(); + let main = t.path(); + fsx::atomic_write( + &main.join("Settings/realm-profile.json"), + br#"{"active":"coa","wildcard_created":true,"reborn_created":true,"simultaneous":true,"with_reborn":true}"#, + ) + .unwrap(); + assert_eq!(world_root(main, Mode::Coa), main); + assert_eq!(world_root(main, Mode::Wildcard), main.join(".realms/secondary")); + assert_eq!(world_root(main, Mode::Reborn), main.join(".realms/reborn")); + // A realm that is not switched on has no world of its own: the main folder answers. + fsx::atomic_write( + &main.join("Settings/realm-profile.json"), + br#"{"active":"coa","wildcard_created":true,"reborn_created":true,"with_reborn":true}"#, + ) + .unwrap(); + assert_eq!(world_root(main, Mode::Wildcard), main); + assert_eq!(world_root(main, Mode::Reborn), main.join(".realms/reborn")); + } + + #[test] + fn reborn_starts_together_with_conquest_of_azeroth_only() { + let t = tempfile::tempdir().unwrap(); + fsx::atomic_write( + &t.path().join("Core/worldserver.exe"), + b"Warcraft Reborn class spells are unavailable", + ) + .unwrap(); + fsx::atomic_write( + &t.path().join("Settings/realm-profile.json"), + br#"{"active":"wildcard","wildcard_created":true}"#, + ) + .unwrap(); + let err = set_reborn_enabled(t.path(), true).unwrap_err().to_string(); + let expected = if cfg!(windows) { + "Select Conquest of Azeroth first" + } else { + "Simultaneous realms currently require Windows" + }; + assert!(err.contains(expected), "{err}"); + assert!(!realms::state(t.path()).unwrap().with_reborn); + } + + #[test] + fn secondary_never_starts_the_shared_database() { + let patched = secondary_launcher("def start_world(config, offset):\r\n start_mysql(config)\r\n wait_ready('world', 8086)\r\n").unwrap(); + assert!(patched.contains("mysql(admin=\"ping\")")); + assert!(!patched.contains("start_mysql(config)")); + assert!(secondary_launcher("def start_world(): pass").is_err()); + } + } + diff --git a/crates/coa-core/src/ra.rs b/crates/coa-core/src/ra.rs index aeca493..c4314cc 100644 --- a/crates/coa-core/src/ra.rs +++ b/crates/coa-core/src/ra.rs @@ -1,597 +1,1276 @@ //! Minimal Remote Access (RA) console client for the worldserver, local only. + //! Only a fixed set of typed operations is exposed; free-form commands are not accepted here. + + use std::io::{Read, Write}; + use std::net::{SocketAddr, TcpStream}; + use std::path::Path; + use std::time::Duration; + + use serde::{Deserialize, Serialize}; + + use crate::error::{Error, Result}; + use crate::fsx; + use crate::layout::read_ports; + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct RaSettings { + ra_username: String, + ra_password: String, + } + + #[derive(Debug)] + pub struct Ra { + stream: TcpStream, + } + + fn read_until(stream: &mut TcpStream, marker: &[u8]) -> Result { + let mut data = Vec::new(); + let mut byte = [0u8; 1]; + while !data.ends_with(marker) { + match stream.read(&mut byte) { + Ok(0) => { + return Err(Error::Invalid( + "The world console closed the connection.".into(), + )) + } + Ok(_) => data.push(byte[0]), + Err(e) => { + return Err(Error::Invalid(format!( + "The world console did not answer: {e}" + ))) + } + } + if data.len() > 256 * 1024 { + return Err(Error::Invalid( + "The world console sent too much data.".into(), + )); + } + } + Ok(String::from_utf8_lossy(&data).into_owned()) + } + + impl Ra { + /// Connect to the loopback RA port of the server in `root`, using the credentials from its `repack.json`. + pub fn connect(root: &Path) -> Result { + let cfg: RaSettings = fsx::read_json(&root.join("Settings/repack.json")) + .map_err(|_| Error::Invalid("The server console settings could not be read.".into()))?; + Self::connect_to(read_ports(root).ra, &cfg.ra_username, &cfg.ra_password) + } + + + /// Connect to the loopback RA port of the target realm world server. + + pub fn connect_target(main: &Path, realm: Option) -> Result { + + let mode = realm.unwrap_or_else(|| { + + crate::realms::view(main).map(|v| v.active).unwrap_or_default() + + }); + + let target = crate::multiworld::resolve_bot_operation_target(main, mode)?; + + if !target.running { + + return Err(Error::Invalid(format!( + + "Realm {} worldserver is stopped; cannot execute online bot operation.", + + target.realm.label() + + ))); + + } + + let cfg: RaSettings = fsx::read_json(&main.join("Settings/repack.json")) + + .map_err(|_| Error::Invalid("The server console settings could not be read.".into()))?; + + Self::connect_to(target.ra_port, &cfg.ra_username, &cfg.ra_password) + + } + + + pub fn connect_to(port: u16, user: &str, password: &str) -> Result { + let addr: SocketAddr = ([127, 0, 0, 1], port).into(); + let mut stream = + TcpStream::connect_timeout(&addr, Duration::from_secs(5)).map_err(|_| { + Error::Invalid( + "The world server is not running (its console is not reachable).".into(), + ) + })?; + stream.set_read_timeout(Some(Duration::from_secs(15)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + read_until(&mut stream, b"Username: ")?; + stream.write_all(format!("{user}\r\n").as_bytes())?; + read_until(&mut stream, b"Password: ")?; + stream.write_all(format!("{password}\r\n").as_bytes())?; + let banner = read_until(&mut stream, b"AC>") + .map_err(|_| Error::Invalid("The world console rejected the login.".into()))?; + if banner.to_lowercase().contains("authentication failed") { + return Err(Error::Invalid( + "The world console rejected the login.".into(), + )); + } + Ok(Ra { stream }) + } + + /// Send one already-validated console command and return its output. + pub fn run(&mut self, cmd: &str) -> Result { + let c = crate::console::check_command(cmd)?; + self.command(c) + } + + fn command(&mut self, cmd: &str) -> Result { + debug_assert!(!cmd.contains(['\r', '\n'])); + self.stream.write_all(format!("{cmd}\r\n").as_bytes())?; + let out = read_until(&mut self.stream, b"AC>")?; + Ok(out.trim_end_matches("AC>").trim().to_string()) + } + + pub fn create_account(&mut self, name: &str, password: &str) -> Result<()> { + validate_account(name, password)?; + let out = self.command(&format!("account create {name} {password}"))?; + let l = out.to_lowercase(); + if l.contains("already exist") { + return Err(Error::Invalid("That account name is already taken.".into())); + } + if l.contains("created") { + Ok(()) + } else { + Err(Error::Invalid(format!( + "The server did not create the account: {}", + out.lines().last().unwrap_or("") + ))) + } + } + + /// `portable checkpoint `: the core saves this one character with a checkpoint marker. Only a + /// number, a session id and a number are sent. The reply is a request status, **not** proof that anything was written. + pub fn portable_checkpoint( + &mut self, + guid: u32, + session: crate::portable::SessionId, + sequence: u64, + ) -> Result { + use crate::portable::session::bridge::CheckpointReply; + let sequence = u32::try_from(sequence) + .map_err(|_| Error::Invalid("The checkpoint sequence is out of range.".into()))?; + let out = self.command(&format!("portable checkpoint {guid} {session} {sequence}"))?; + let first = out + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .unwrap_or(""); + Ok(match first.split_whitespace().next() { + Some("QUEUED") => CheckpointReply::Queued, + Some("BUSY") => CheckpointReply::Busy, + Some("NOT_ONLINE") => CheckpointReply::NotOnline, + Some("REFUSED") => { + CheckpointReply::Refused(first.trim_start_matches("REFUSED").trim().to_string()) + } + _ => CheckpointReply::Refused(format!("unexpected answer: {first}")), + }) + } + + /// `portable import `: the core imports the job file of that id from its fixed job directory. Only the id travels. + pub fn portable_import(&mut self, job_id: crate::portable::ImportId) -> Result { + let out = self.command(&format!("portable import {job_id}"))?; + let first = out + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .unwrap_or("") + .to_string(); + if first.starts_with("OK") { + Ok(first) + } else { + Err(Error::Invalid(format!( + "The server did not import the job: {first}" + ))) + } + } + + /// `portable project `: the core decides what a projection of the job file's character at its level cap holds, and writes the + /// decision to the job's result file. Only the id travels. + pub fn portable_project(&mut self, job_id: crate::portable::ImportId) -> Result { + let out = self.command(&format!("portable project {job_id}"))?; + let first = out + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .unwrap_or("") + .to_string(); + if first.starts_with("OK") { + Ok(first) + } else { + Err(Error::Invalid(format!( + "The server did not answer the projection query: {first}" + ))) + } + } + + /// `portable capabilities`: the realm's core says what it is and what portable features it has. An older core does not know the command. + pub fn portable_capabilities(&mut self) -> Result { + let out = self.command("portable capabilities")?; + crate::portable::capabilities::CoreReport::parse(&out) + .map_err(|e| Error::Invalid(e.to_string())) + } + + /// `portable release `: lets a held portable character play. Sent only after its baseline was persisted. + pub fn portable_release(&mut self, session: crate::portable::SessionId) -> Result<()> { + let out = self.command(&format!("portable release {session}"))?; + if out.lines().any(|l| l.trim().starts_with("OK")) { + Ok(()) + } else { + Err(Error::Invalid(format!( + "The server did not release the session: {}", + out.lines().last().unwrap_or("") + ))) + } + } + + /// World update timing from the server's own `server info` report (mean/median/percentiles of the last 500 updates). + pub fn performance(&mut self) -> Result> { + let out = self.command("server info")?; + Ok(parse_server_info(&out)) + } + + fn bot_command(&mut self, cmd: &str) -> Result { + let out = self.command(cmd)?; + if is_unsupported_command(&out) { + return Err(Error::CompanionCommandUnsupported); + } + Ok(out) + } + + /// Drop every bot still waiting to be created. Returns how many were waiting. + pub fn cancel_spawning(&mut self) -> Result { + Ok(first_number(&self.bot_command("botcmd spawncancel")?)) + } + + /// Log every bot out without deleting anything. Returns how many were online. + pub fn despawn_all(&mut self) -> Result { + Ok(first_number(&self.bot_command("botcmd despawnall")?)) + } + + /// Log one bot out (the character stays saved). False when that bot was not online. + pub fn despawn_bot(&mut self, guid: u64) -> Result { + Ok(self + .bot_command(&format!("botcmd despawn {guid}"))? + .to_lowercase() + .contains("despawned")) + } + + /// Log every bot out and delete every bot character for good (the caller saves a recovery point first). + pub fn purge_all(&mut self) -> Result { + let out = self.bot_command("botcmd purgeall")?; + Ok(out.lines().last().unwrap_or("").to_string()) + } + + /// Ask the bot module to create `count` leveling bots (throttled by the module itself). Only a number is sent. + pub fn spawn_bots(&mut self, count: u32) -> Result { + if !(1..=2000).contains(&count) { + return Err(Error::Invalid( + "Choose between 1 and 2000 companions.".into(), + )); + } + let out = self.command(&format!("botcmd spawnleveled {count}"))?; + if out.to_lowercase().contains("no usable template characters") { + return Err(Error::CompanionTemplatesMissing); + } + Ok(out.lines().last().unwrap_or("").to_string()) + } + + /// Ask the bot module to spawn a fleet of bots of specific race and level (.botcmd spawnfleet ). + pub fn spawn_fleet(&mut self, race: u8, level: u8) -> Result { + let out = self.bot_command(&format!("botcmd spawnfleet {race} {level}"))?; + Ok(out.lines().last().unwrap_or("").to_string()) + } + + + pub fn spawn_custom( + + &mut self, + + count: u32, + + level: u8, + + class_id: Option, + + spec_id: Option, + + archetype: Option<&str>, + + ) -> Result { + + let mut cmd = format!("botcmd spawncustom {count} {level}"); + + if let Some(c) = class_id { + + cmd.push_str(&format!(" {c}")); + + if let Some(s) = spec_id { + + cmd.push_str(&format!(" {s}")); + + if let Some(a) = archetype { + + if !a.is_empty() { + + cmd.push_str(&format!(" {a}")); + + } + + } + + } + + } + + let out = self.bot_command(&cmd)?; + + Ok(out.lines().last().unwrap_or("").to_string()) + + } + + + /// Bring all offline bots online gradually (.botcmd loginall). + pub fn login_all(&mut self) -> Result { + let out = self.bot_command("botcmd loginall")?; + Ok(out.lines().last().unwrap_or("").to_string()) + } + + /// Set a new password for an existing account. + pub fn set_account_password(&mut self, name: &str, password: &str) -> Result<()> { + validate_account(name, password)?; + let out = self.command(&format!( + "account set password {name} {password} {password}" + ))?; + account_reply(&out, "The password was not changed") + } + + /// Access level of an account on all realms: 0 player, 1 moderator, 2 game master, 3 administrator. + pub fn set_account_access(&mut self, name: &str, level: u8) -> Result<()> { + validate_account(name, "placeholder")?; + if level > 3 { + return Err(Error::Invalid("The access level must be 0 to 3.".into())); + } + let out = self.command(&format!("account set gmlevel {name} {level} -1"))?; + account_reply(&out, "The access level was not changed") + } + + /// Delete an account together with its characters (the console's `account delete`). + pub fn delete_account(&mut self, name: &str) -> Result<()> { + validate_account(name, "placeholder")?; + let out = self.command(&format!("account delete {name}"))?; + account_reply(&out, "The account was not deleted") + } + + /// Give `name` administrator rights on all realms (GM level 3). + pub fn make_administrator(&mut self, name: &str) -> Result<()> { + validate_account(name, "placeholder")?; + let out = self.command(&format!("account set gmlevel {name} 3 -1"))?; + if out.to_lowercase().contains("gm level") + || out.to_lowercase().contains("security") + || out.is_empty() + { + Ok(()) + } else { + Err(Error::Invalid(format!( + "Could not set administrator rights: {out}" + ))) + } + } + } + + /// The console reports a failed account command in words; anything else (including silence) counts as done. + fn account_reply(out: &str, what: &str) -> Result<()> { + let l = out.to_lowercase(); + let failed = [ + "does not exist", + "not exist", + "not found", + "do not match", + "don't match", + "usage", + "unknown", + "incorrect", + "error", + ]; + if failed.iter().any(|m| l.contains(m)) { + return Err(Error::Invalid(format!( + "{what}: {}", + out.lines().last().unwrap_or("").trim() + ))); + } + Ok(()) + } + + /// The console answers an unknown sub-command with the list of the ones it knows. + fn is_unsupported_command(out: &str) -> bool { + let l = out.to_lowercase(); + l.contains("possible subcommands") + || l.contains("### usage") + || l.contains("no such command") + || l.contains("unknown command") + } + + fn first_number(text: &str) -> u32 { + text.split(|c: char| !c.is_ascii_digit()) + .find(|t| !t.is_empty()) + .and_then(|t| t.parse().ok()) + .unwrap_or(0) + } + + /// How fast the world loop is running, read from `server info`. + #[derive(Debug, Clone, Serialize, PartialEq)] + pub struct Performance { + pub mean_ms: u32, + pub median_ms: u32, + pub p95_ms: u32, + pub p99_ms: u32, + pub max_ms: u32, + /// World updates per second implied by the mean update time. + pub ticks_per_sec: f32, + } + + fn ms(text: &str) -> Option { + text.trim().trim_end_matches("ms").trim().parse().ok() + } + + pub fn parse_server_info(text: &str) -> Option { + let (mut mean, mut median, mut pct) = (None, None, None); + for line in text.lines() { + let line = line + .trim() + .trim_start_matches('|') + .trim_start_matches('-') + .trim(); + if let Some(v) = line.strip_prefix("Mean:") { + mean = ms(v); + } else if let Some(v) = line.strip_prefix("Median:") { + median = ms(v); + } else if let Some(v) = line.strip_prefix("Percentiles (95, 99, max):") { + let p: Vec> = v.split(',').map(ms).collect(); + if p.len() == 3 { + pct = Some((p[0]?, p[1]?, p[2]?)); + } + } + } + let (mean_ms, median_ms, (p95_ms, p99_ms, max_ms)) = (mean?, median?, pct?); + Some(Performance { + mean_ms, + median_ms, + p95_ms, + p99_ms, + max_ms, + ticks_per_sec: 1000.0 / mean_ms.max(1) as f32, + }) + } + + /// Account names are letters/digits (3-17); passwords are 6-16 printable characters without spaces or quotes. + pub fn validate_account(name: &str, password: &str) -> Result<()> { + let bad = |m: &str| Err(Error::Invalid(m.into())); + // an underscore inside a name is allowed: the Manager gives joining players names like `DMITRY_7K4M` + if !(3..=17).contains(&name.len()) + || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + || name.starts_with('_') + || name.ends_with('_') + { + return bad("The username must be 3–17 letters or digits."); + } + if !(6..=16).contains(&password.len()) + || !password + .chars() + .all(|c| c.is_ascii_graphic() && c != '"' && c != '\'') + { + return bad("The password must be 6–16 characters (no spaces or quotes)."); + } + Ok(()) + } + + #[cfg(test)] + mod tests { + use super::*; + use std::io::{BufRead, BufReader}; + use std::net::TcpListener; + + /// These tests share the loopback interface; on some machines (security software hooking TCP) several + /// concurrent short-lived loopback connections are occasionally reset, so they run one at a time. + static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn serial() -> std::sync::MutexGuard<'static, ()> { + SERIAL.lock().unwrap_or_else(|e| e.into_inner()) + } + + fn fake_ra(reply: &'static str) -> (u16, std::thread::JoinHandle>) { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let h = std::thread::spawn(move || { + let (mut s, _) = l.accept().unwrap(); + let mut r = BufReader::new(s.try_clone().unwrap()); + let mut got = Vec::new(); + s.write_all(b"Username: ").unwrap(); + let mut line = String::new(); + r.read_line(&mut line).unwrap(); + got.push(line.trim().to_string()); + s.write_all(b"Password: ").unwrap(); + line.clear(); + r.read_line(&mut line).unwrap(); + got.push(line.trim().to_string()); + s.write_all(b"Authentication Succeeded\r\nAC>").unwrap(); + line.clear(); + if r.read_line(&mut line).unwrap_or(0) > 0 { + got.push(line.trim().to_string()); + s.write_all(format!("{reply}\r\nAC>").as_bytes()).unwrap(); + } + // Close gracefully: dropping a socket with unread data would send RST and could discard the reply. + let _ = s.shutdown(std::net::Shutdown::Write); + let _ = s.set_read_timeout(Some(Duration::from_secs(10))); + let mut sink = [0u8; 64]; + while matches!(s.read(&mut sink), Ok(n) if n > 0) {} + got + }); + (port, h) + } + + #[test] + fn an_unknown_bot_subcommand_is_recognised_and_numbers_are_read_from_replies() { + assert!(is_unsupported_command( + "### USAGE: .botcmd ... + Possible subcommands: + |- botcmd despawn" + )); + assert!(!is_unsupported_command( + "BotMgr: cancelled 35 queued bot spawn(s)." + )); + assert_eq!( + first_number("BotMgr: cancelled 35 queued bot spawn(s)."), + 35 + ); + assert_eq!(first_number("nothing"), 0); + } + + #[test] + fn server_info_timing_is_parsed_and_unrelated_text_is_ignored() { + let text = "AzerothCore rev. x + Connected players: 0. Characters in world: 0. + Update time diff: 1ms. Last 500 diffs summary: + |- Mean: 14ms + |- Median: 15ms + |- Percentiles (95, 99, max): 24ms, 30ms, 61ms + AC>"; + let p = parse_server_info(text).unwrap(); + assert_eq!( + (p.mean_ms, p.median_ms, p.p95_ms, p.p99_ms, p.max_ms), + (14, 15, 24, 30, 61) + ); + assert!((p.ticks_per_sec - 71.4).abs() < 0.2); + assert!(parse_server_info("Connected players: 0.").is_none()); + } + + #[test] + fn creates_an_account_over_ra_with_the_expected_commands() { + let _lock = serial(); + let (port, h) = fake_ra("Account created: PLAYER1"); + let mut ra = Ra::connect_to(port, "local", "secretra").unwrap(); + ra.create_account("Player1", "hunter22").unwrap(); + drop(ra); + assert_eq!( + h.join().unwrap(), + ["local", "secretra", "account create Player1 hunter22"] + ); + } + + #[test] + fn changes_a_password_with_the_expected_command() { + let _lock = serial(); + let (port, h) = fake_ra("The password was changed"); + let mut ra = Ra::connect_to(port, "u", "p").unwrap(); + ra.set_account_password("Player1", "newpass9").unwrap(); + drop(ra); + assert_eq!( + h.join().unwrap()[2], + "account set password Player1 newpass9 newpass9" + ); + } + + #[test] + fn changes_an_access_level_with_the_expected_command() { + let _lock = serial(); + let (port, h) = fake_ra("You have changed security level of Player1 to 2."); + let mut ra = Ra::connect_to(port, "u", "p").unwrap(); + ra.set_account_access("Player1", 2).unwrap(); + assert!(ra.set_account_access("Player1", 4).is_err(), "only 0 to 3"); + drop(ra); + assert_eq!(h.join().unwrap()[2], "account set gmlevel Player1 2 -1"); + } + + #[test] + fn a_failed_account_command_is_reported_and_bad_names_never_reach_the_console() { + let _lock = serial(); + let (port, h) = fake_ra("Account not found."); + let mut ra = Ra::connect_to(port, "u", "p").unwrap(); + assert!( + ra.set_account_password("x", "newpass9").is_err(), + "the name is validated before anything is sent" + ); + let error = ra + .set_account_password("Nobody", "newpass9") + .unwrap_err() + .to_string(); + assert!( + error.contains("not found"), + "Unexpected console error: {error}" + ); + drop(ra); + let _ = h.join(); + } + + #[test] + fn spawn_bots_sends_only_a_bounded_number() { + let _lock = serial(); + let (port, h) = fake_ra("Queued 50 bots"); + let mut ra = Ra::connect_to(port, "u", "p").unwrap(); + assert_eq!(ra.spawn_bots(50).unwrap(), "Queued 50 bots"); + assert!(ra.spawn_bots(0).is_err() && ra.spawn_bots(5000).is_err()); + drop(ra); + assert_eq!(h.join().unwrap()[2], "botcmd spawnleveled 50"); + } + + #[test] + fn duplicate_account_and_input_validation() { + let _lock = serial(); + let (port, h) = fake_ra("Account already exist."); + let mut ra = Ra::connect_to(port, "u", "p").unwrap(); + assert!(ra + .create_account("Player1", "hunter22") + .unwrap_err() + .to_string() + .contains("already taken")); + drop(ra); + let _ = h.join(); + for (n, p) in [ + ("ab", "hunter22"), + ("bad name", "hunter22"), + ("Player1", "short"), + ("Player1", "has space1"), + ("Player1", "quote\"pw1"), + ("Pl\r\nayer", "hunter22"), + ("x".repeat(18).as_str(), "hunter22"), + ] { + assert!(validate_account(n, p).is_err(), "{n:?} {p:?}"); + } + validate_account("Player1", "hunter22").unwrap(); + } + + #[test] + fn unreachable_console_gives_a_friendly_error() { + let _lock = serial(); + // Port 1 is never used by a game server; probing a freed ephemeral port would race with the other tests. + assert!(Ra::connect_to(1, "u", "p") + .unwrap_err() + .to_string() + .contains("not running")); + } + } + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0f1cd5b..6c2b51a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1771,11 +1771,12 @@ async fn get_population( async fn companions_login_all( state: State<'_, AppState>, id: String, + realm: Option, ) -> std::result::Result { let root = path_of(&state, &id)?; let _guard = BusyGuard::acquire(&state, &id)?; blocking(move || { - let mut ra = Ra::connect(&root)?; + let mut ra = Ra::connect_target(&root, realm)?; ra.login_all() }) .await @@ -1787,11 +1788,12 @@ async fn companions_spawn_fleet( id: String, race: u8, level: u8, + realm: Option, ) -> std::result::Result { let root = path_of(&state, &id)?; let _guard = BusyGuard::acquire(&state, &id)?; blocking(move || { - let mut ra = Ra::connect(&root)?; + let mut ra = Ra::connect_target(&root, realm)?; ra.spawn_fleet(race, level) }) .await @@ -1807,11 +1809,12 @@ struct CompanionAction { async fn companions_stop_spawning( state: State<'_, AppState>, id: String, + realm: Option, ) -> std::result::Result { let root = path_of(&state, &id)?; blocking(move || { Ok(CompanionAction { - count: Ra::connect(&root)?.cancel_spawning()?, + count: Ra::connect_target(&root, realm)?.cancel_spawning()?, }) }) .await @@ -1821,12 +1824,12 @@ async fn companions_stop_spawning( async fn companions_take_offline( state: State<'_, AppState>, id: String, + realm: Option, ) -> std::result::Result { let root = path_of(&state, &id)?; let _guard = BusyGuard::acquire(&state, &id)?; blocking(move || { - let mut ra = Ra::connect(&root)?; - // Anything still waiting to be created must not come back right after. + let mut ra = Ra::connect_target(&root, realm)?; let _ = ra.cancel_spawning(); Ok(CompanionAction { count: ra.despawn_all()?, @@ -1835,13 +1838,12 @@ async fn companions_take_offline( .await } -/// Log `count` randomly chosen online companions out to lower the load. Uses the per-bot command that every server -/// build has, so it works on older builds too. #[tauri::command] async fn companions_despawn_some( state: State<'_, AppState>, id: String, count: u32, + realm: Option, ) -> std::result::Result { let root = path_of(&state, &id)?; let _guard = BusyGuard::acquire(&state, &id)?; @@ -1849,13 +1851,18 @@ async fn companions_despawn_some( if !(1..=5000).contains(&count) { return Err(Error::Invalid("Choose a number between 1 and 5000.".into())); } + let mode = realm.unwrap_or_else(|| { + coa_core::realms::view(&root).map(|v| v.active).unwrap_or_default() + }); + let target = coa_core::multiworld::resolve_bot_operation_target(&root, mode)?; let prefix = coa_core::population::bot_account_prefix(&root).to_uppercase(); let db = coa_core::db::Db::from_repack(&root, coa_core::db::Account::App)?; let rows = db.query(&format!( - "SELECT c.guid FROM acore_characters.characters c JOIN acore_auth.account a ON a.id=c.account WHERE c.online=1 AND UPPER(a.username) LIKE '{prefix}%' ORDER BY RAND() LIMIT {count};" + "SELECT c.guid FROM {}.characters c JOIN acore_auth.account a ON a.id=c.account WHERE c.online=1 AND UPPER(a.username) LIKE '{prefix}%' ORDER BY RAND() LIMIT {count};", + target.chars_db ))?; let guids: Vec = rows.lines().filter_map(|l| l.trim().parse().ok()).collect(); - let mut ra = Ra::connect(&root)?; + let mut ra = Ra::connect_target(&root, Some(mode))?; let mut done = 0; for g in guids { if ra.despawn_bot(g).unwrap_or(false) { @@ -1867,31 +1874,35 @@ async fn companions_despawn_some( .await } -/// Delete every companion for good. A recovery point of the characters and accounts is saved first. #[tauri::command] async fn companions_delete_all( app: AppHandle, state: State<'_, AppState>, id: String, + realm: Option, ) -> std::result::Result { let root = path_of(&state, &id)?; let _guard = BusyGuard::acquire(&state, &id)?; blocking(move || { + let mode = realm.unwrap_or_else(|| { + coa_core::realms::view(&root).map(|v| v.active).unwrap_or_default() + }); + let target = coa_core::multiworld::resolve_bot_operation_target(&root, mode)?; let meta = meta_dir(&root)?; backup::create( &root, &meta, Kind::Quick, Trigger::BeforeDangerousChange, - Some("before deleting all companions".into()), + Some(format!("before deleting all {} companions", target.realm.label())), &|step| { let _ = app.emit("backup-progress", step); }, )?; - let before = coa_core::population::query(&root) + let before = coa_core::population::query_for_realm(&root, mode) .map(|p| p.bots_total) .unwrap_or(0); - let mut ra = Ra::connect(&root)?; + let mut ra = Ra::connect_target(&root, Some(mode))?; let _ = ra.cancel_spawning(); ra.purge_all()?; Ok(CompanionAction { count: before }) @@ -1948,6 +1959,10 @@ async fn add_companions( id: String, count: u32, realm: Option, + level: Option, + class_id: Option, + spec_id: Option, + archetype: Option, ) -> std::result::Result { let root = path_of(&state, &id)?; let _guard = BusyGuard::acquire(&state, &id)?; @@ -1960,15 +1975,10 @@ async fn add_companions( let mode = realm.unwrap_or_else(|| { coa_core::realms::view(&root).map(|v| v.active).unwrap_or_default() }); - let chars_db = match mode { - coa_core::realms::Mode::Coa => "acore_characters", - coa_core::realms::Mode::Wildcard => "acore_characters_wildcard", - coa_core::realms::Mode::Reborn => "acore_characters_reborn", - }; + let target = coa_core::multiworld::resolve_bot_operation_target(&root, mode)?; let meta = meta_dir(&root)?; let mut changes = BTreeMap::new(); changes.insert("CoaBots.AutoLoginOnStartup".to_string(), Value::Bool(true)); - // Every companion, old and new, should come back at the next start, so the start-up limit only ever grows here. let existing_max = config::load(&root, Scope::Bots) .ok() .and_then(|v| { @@ -1986,56 +1996,22 @@ async fn add_companions( Value::from(existing_max.max(already + count).min(5000)), ); config::save(&root, &meta, Scope::Bots, &changes)?; - let o = coa_core::process::observe(&root, &layout::read_ports(&root)); - // Large batches while the server is stopped: create them in the database right now, fully equipped (CoA schema only). - if o.world.state == coa_core::process::ServiceState::Stopped - && o.auth.state == coa_core::process::ServiceState::Stopped - && count >= coa_core::companions::OFFLINE_MIN - && mode == coa_core::realms::Mode::Coa - && coa_core::companions::offline_factory(&root).is_some() - { - let started_db = o.mysql.state != coa_core::process::ServiceState::Running; - if started_db { - let out = driver::run(&root, Verb::StartMysql)?; - if !out.ok { - return Err(Error::Invalid("The database could not be started.".into())); - } - } - let made = (|| -> Result<()> { - coa_core::companions::ensure_templates_for_schema( - &coa_core::db::Db::from_repack(&root, coa_core::db::Account::Admin)?, - chars_db, - )?; - coa_core::companions::offline_create( - &root, - &meta.join("logs").join("companions-offline.log"), - count, - )?; - Ok(()) - })(); - if started_db { - let _ = driver::run(&root, Verb::StopAll); - } - made?; - tracing::info!(count, "companions created offline"); - return Ok(CompanionsResult { - spawned: None, - created: Some(count), - baseline: None, - }); - } + let mut baseline = None; - let spawned = if o.world.state == coa_core::process::ServiceState::Running { - // Older servers have no template characters to copy bots from: add them first. + let spawned = if target.running { if let Ok(admin_db) = coa_core::db::Db::from_repack(&root, coa_core::db::Account::Admin) { - let _ = coa_core::companions::ensure_templates_for_schema(&admin_db, chars_db); + let _ = coa_core::companions::ensure_templates_for_schema(&admin_db, target.chars_db); } baseline = coa_core::population::query_for_realm(&root, mode) .ok() .map(|p| p.bots_total); - Some(Ra::connect(&root)?.spawn_bots(count)?) + let mut ra = Ra::connect_target(&root, Some(mode))?; + if let Some(lvl) = level { + Some(ra.spawn_custom(count, lvl, class_id, spec_id, archetype.as_deref())?) + } else { + Some(ra.spawn_bots(count)?) + } } else { - // The server is stopped: creating bots needs it running, so remember the request and do it after the next start. coa_core::fsx::atomic_write_json( &meta.join("companions.pending.json"), &serde_json::json!({ "count": count }), diff --git a/src/lib/api.ts b/src/lib/api.ts index 044b046..92e40b7 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -813,17 +813,33 @@ export const api = { rollbackUpdate: (id: string, txn: string) => invoke("rollback_update", { id, txn }), retryUpdateValidation: (id: string, txn: string) => invoke("retry_update_validation", { id, txn }), population: (id: string, realm?: RealmMode) => invoke("get_population", { id, realm: realm ?? null }), - stopSpawning: (id: string) => invoke<{ count: number }>("companions_stop_spawning", { id }), - takeOffline: (id: string) => invoke<{ count: number }>("companions_take_offline", { id }), - despawnSome: (id: string, count: number) => invoke<{ count: number }>("companions_despawn_some", { id, count }), - deleteAllCompanions: (id: string) => invoke<{ count: number }>("companions_delete_all", { id }), - loginAllCompanions: (id: string) => invoke("companions_login_all", { id }), - spawnBotFleet: (id: string, race: number, level: number) => invoke("companions_spawn_fleet", { id, race, level }), + stopSpawning: (id: string, realm?: RealmMode) => invoke<{ count: number }>("companions_stop_spawning", { id, realm: realm ?? null }), + takeOffline: (id: string, realm?: RealmMode) => invoke<{ count: number }>("companions_take_offline", { id, realm: realm ?? null }), + despawnSome: (id: string, count: number, realm?: RealmMode) => invoke<{ count: number }>("companions_despawn_some", { id, count, realm: realm ?? null }), + deleteAllCompanions: (id: string, realm?: RealmMode) => invoke<{ count: number }>("companions_delete_all", { id, realm: realm ?? null }), + loginAllCompanions: (id: string, realm?: RealmMode) => invoke("companions_login_all", { id, realm: realm ?? null }), + spawnBotFleet: (id: string, race: number, level: number, realm?: RealmMode) => invoke("companions_spawn_fleet", { id, race, level, realm: realm ?? null }), openLink: (url: string) => invoke("open_link", { url }), performance: (id: string) => invoke("get_performance", { id }), companionSizes: () => invoke("companion_sizes"), - addCompanions: (id: string, count: number, realm?: RealmMode) => - invoke<{ spawned: string | null; created: number | null; baseline: number | null }>("add_companions", { id, count, realm: realm ?? null }), + addCompanions: ( + id: string, + count: number, + realm?: RealmMode, + level?: number, + classId?: number, + specId?: number, + archetype?: string + ) => + invoke<{ spawned: string | null; created: number | null; baseline: number | null }>("add_companions", { + id, + count, + realm: realm ?? null, + level: level ?? null, + class_id: classId ?? null, + spec_id: specId ?? null, + archetype: archetype ?? null, + }), clientGraphics: (id: string) => invoke<{ dxvk_disabled: boolean; enhancements_disabled: boolean }>("client_graphics", { id }), clientSetGraphics: (id: string, name: string, disabled: boolean) => invoke<{ dxvk_disabled: boolean; enhancements_disabled: boolean }>("client_set_graphics", { id, name, disabled }), clientInfo: (id: string) => invoke("client_info", { id }), diff --git a/src/screens/CompanionsCard.tsx b/src/screens/CompanionsCard.tsx index 3ca88ac..c4dd45a 100644 --- a/src/screens/CompanionsCard.tsx +++ b/src/screens/CompanionsCard.tsx @@ -55,8 +55,9 @@ export function CompanionsCard({ serverId }: { serverId: string }) { const activeServerRealm = profiles?.active ?? "coa"; const isViewingActiveRealm = selectedRealm === activeServerRealm; + const isRealmOperational = isViewingActiveRealm || running; - const maxLevelForRealm = selectedRealm === "reborn" ? 60 : 80; + const maxLevelForRealm = 60; // Load realm profiles const loadProfiles = async () => { @@ -158,12 +159,27 @@ export function CompanionsCard({ serverId }: { serverId: string }) { setNote(null); try { if (spawnMode === "fleet") { - // Fleet spawn: race 1 (Human) or 2 (Orc) const race = 1; - const out = await api.spawnBotFleet(serverId, race, targetLevel); + const out = await api.spawnBotFleet(serverId, race, targetLevel, selectedRealm); setNote(out || `Spawned level ${targetLevel} fleet`); } else { - const r = await api.addCompanions(serverId, count, selectedRealm); + const classNum = selectedClass !== "all" ? parseInt(selectedClass, 10) : undefined; + let specNum: number | undefined = undefined; + if (selectedRole === "tank") { + if (selectedRealm === "reborn") { + specNum = (classNum === 7 || classNum === 9 || classNum === 11) ? 4 : (classNum === 2 ? 2 : classNum === 1 ? 3 : 1); + } else if (selectedRealm === "wildcard") { + specNum = 1; + } + } else if (selectedRole === "healer") { + if (selectedRealm === "reborn") { + specNum = (classNum === 2 || classNum === 5) ? 1 : (classNum === 7 || classNum === 11 ? 3 : 1); + } else if (selectedRealm === "wildcard") { + specNum = 2; + } + } + const archStr = selectedArchetype !== "all" ? selectedArchetype : undefined; + const r = await api.addCompanions(serverId, count, selectedRealm, targetLevel, classNum, specNum, archStr); if (r.spawned && r.baseline !== null) { setJob({ baseline: r.baseline, target: count, since: Date.now() }); } @@ -186,7 +202,7 @@ export function CompanionsCard({ serverId }: { serverId: string }) { // Bring offline bots online const handleLoginAll = () => act(async () => { - const out = await api.loginAllCompanions(serverId); + const out = await api.loginAllCompanions(serverId, selectedRealm); await fetchPopulation(); return out || "All available offline bots queued for login."; }); @@ -194,7 +210,7 @@ export function CompanionsCard({ serverId }: { serverId: string }) { // Stop queued creations const stopCreating = () => act(async () => { - const r = await api.stopSpawning(serverId); + const r = await api.stopSpawning(serverId, selectedRealm); setJob(null); await fetchPopulation(); return t("comp.stopped", { n: r.count }); @@ -204,7 +220,7 @@ export function CompanionsCard({ serverId }: { serverId: string }) { const takeOffline = () => { if (!window.confirm(t("comp.offlineConfirm"))) return; void act(async () => { - const r = await api.takeOffline(serverId); + const r = await api.takeOffline(serverId, selectedRealm); setJob(null); await fetchPopulation(); return t("comp.offlineDone2", { n: r.count }); @@ -216,7 +232,7 @@ export function CompanionsCard({ serverId }: { serverId: string }) { const dnValid = Number.isInteger(dn) && dn >= 1 && dn <= 5000; const despawnSome = () => act(async () => { - const r = await api.despawnSome(serverId, dn); + const r = await api.despawnSome(serverId, dn, selectedRealm); await fetchPopulation(); return t("comp.despawnSomeDone", { n: r.count }); }); @@ -225,7 +241,7 @@ export function CompanionsCard({ serverId }: { serverId: string }) { const deleteAll = () => { if (!window.confirm(t("comp.deleteConfirm", { n: pop?.bots_total ?? 0 }))) return; void act(async () => { - await api.deleteAllCompanions(serverId); + await api.deleteAllCompanions(serverId, selectedRealm); setJob(null); await fetchPopulation(); return t("comp.deleteDone"); @@ -514,17 +530,27 @@ export function CompanionsCard({ serverId }: { serverId: string }) { {selectedRealm === "coa" && ( <> - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + )} {selectedRealm === "wildcard" && ( @@ -605,7 +631,7 @@ export function CompanionsCard({ serverId }: { serverId: string }) {