diff --git a/Cargo.lock b/Cargo.lock index 630c791..86f504d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,7 +347,7 @@ dependencies = [ [[package]] name = "coa-core" -version = "0.4.0" +version = "0.5.0" dependencies = [ "base64 0.22.1", "chrono", @@ -375,7 +375,7 @@ dependencies = [ [[package]] name = "coa-release" -version = "0.4.0" +version = "0.5.0" dependencies = [ "chrono", "coa-core", @@ -384,7 +384,7 @@ dependencies = [ [[package]] name = "coa-server-manager" -version = "0.4.0" +version = "0.5.0" dependencies = [ "coa-core", "serde", diff --git a/Cargo.toml b/Cargo.toml index f4cdd47..99b38df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/coa-core", "crates/coa-release", "src-tauri"] [workspace.package] -version = "0.4.0" +version = "0.5.0" edition = "2021" license = "AGPL-3.0-only" diff --git a/README.md b/README.md index 5c8f804..d4890e5 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,12 @@ no console, no config files, no database tools. Tauri 2 + Rust + React. into **UPDATE**. Updates are authenticated (Ed25519), back up first, keep files you changed and can be rolled back. * **Backups.** Characters, accounts and settings on demand, automatically before every update; verify and restore from the app. +* **CoA / Wildcard realms.** Choose the active realm on Overview. A server build with Wildcard support is required; + the first switch copies world data and creates an empty character database. Accounts are shared, characters and + module settings are separate, and only one world runs. Switching a running server saves characters and restarts it. + CoA companions are unavailable on Wildcard; other optional modules are experimental. Backups include both realms, + and database updates apply to both. + See [realm profiles and validation](docs/WILDCARD.md). * **Companions (bots).** Fill the world with level-appropriate bots, see progress, stop spawning, take them offline, despawn N to relieve the server, and choose how many log in at server start. * **Realmlist switcher.** Keep named realmlists (for example *Solo* and *PTR*) and switch the game client between them from a diff --git a/changelog.d/20261003-wildcard-realm-profiles.md b/changelog.d/20261003-wildcard-realm-profiles.md new file mode 100644 index 0000000..59e45c7 --- /dev/null +++ b/changelog.d/20261003-wildcard-realm-profiles.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Switch between CoA and Wildcard while keeping separate characters and module settings +--- +Run one world at a time with shared accounts. Wildcard keeps its own progress; CoA companions are unavailable there, and other modules show experimental compatibility. Backups and server updates cover both realms. diff --git a/crates/coa-core/examples/realm_e2e.rs b/crates/coa-core/examples/realm_e2e.rs new file mode 100644 index 0000000..af08337 --- /dev/null +++ b/crates/coa-core/examples/realm_e2e.rs @@ -0,0 +1,92 @@ +//! Native smoke checks for the disposable fixture created by tools/make-fixture.ps1. +//! Never point these probes at an owned server. +use std::path::Path; + +use coa_core::db::{Account, Db}; +use coa_core::driver::{self, Verb}; +use coa_core::realms::{self, Mode}; +use coa_core::{Error, Result}; + +fn main() -> Result<()> { + let args: Vec = std::env::args().collect(); + let root = Path::new("C:/games/coa-wildcard-fixture"); + let action = args.get(1).map(String::as_str).unwrap_or("view"); + match action { + "select" => { + let mode = if args.get(2).map(String::as_str) == Some("wildcard") { Mode::Wildcard } else { Mode::Coa }; + println!("{}", serde_json::to_string(&realms::select(root, mode)?)?); + } + "start" | "stop" => { + let out = driver::run(root, if action == "start" { Verb::StartAll } else { Verb::StopAll })?; + println!("{}", serde_json::to_string(&out)?); + if !out.ok { return Err(Error::Invalid("Fixture operation failed".into())); } + } + "probe" => { + let db = Db::from_repack(root, Account::Admin)?; + let s = realms::state(root)?; + println!("{}", db.query("SELECT * FROM acore_characters.manager_profile_probe;")?); + println!("active={} chars={} world={}", s.active.name(), s.active.schema("characters")?, s.active.schema("world")?); + } + "mark" => { + Db::from_repack(root, Account::Admin)?.query("INSERT INTO acore_characters.manager_profile_probe VALUES (22);")?; + } + "backup" => { + use coa_core::backup::{self, Kind, Trigger}; + let meta = root.join("fixture-manager"); + let point = backup::create(root, &meta, Kind::Full, Trigger::Manual, Some("realm isolation check".into()), &|step| println!("{step}"))?; + assert_eq!(point.realm, Mode::Wildcard); + for name in ["characters", "world", "auth", "coa-characters", "coa-world", "configs"] { + assert!(point.components.iter().any(|c| c.name == name), "Missing {name}"); + } + assert!(backup::verify(&meta, &point.id)?.ok); + println!("Verified both realm backups: {}", point.id); + } + "database-checks" => { + use coa_core::backup::{self, Kind, Trigger}; + use coa_core::manifest::{Manifest, Migration}; + use coa_core::update::{Env, RepackEnv}; + let meta = root.join("fixture-manager"); + let env = RepackEnv { root, meta_dir: &meta }; + env.ensure_stopped()?; + let staged = root.join("fixture-migrations"); + let mut manifest: Manifest = serde_json::from_value(serde_json::json!({ + "schema": 1, "kind": "update", "version": "0.5.0", "core": {"commit": null}, + "builtAt": "fixture", "minManagerVersion": "0.5.0" + }))?; + for kind in ["characters", "world", "auth"] { + let sql = format!("CREATE TABLE acore_{kind}.manager_migration_probe (value INT); INSERT INTO acore_{kind}.manager_migration_probe VALUES (42);"); + let path = staged.join(format!("{kind}/manager_realm_fixture.sql")); + std::fs::create_dir_all(path.parent().unwrap())?; + std::fs::write(&path, sql)?; + manifest.migrations.push(Migration { id: "manager_realm_fixture".into(), db: kind.into(), sha256: coa_core::fsx::sha256_file(&path)?, destructive: false }); + } + let result = env.migrate(&manifest, &staged)?; + assert!(result.failed.is_none()); + backup::with_database(root, |db| { + for mode in [Mode::Coa, Mode::Wildcard] { + let realm = db.clone().for_realm(mode); + for kind in ["characters", "world", "auth"] { + assert_eq!(realm.query(&format!("SELECT COUNT(*) FROM acore_{kind}.manager_migration_probe;"))?, "1"); + } + } + Ok(()) + })?; + assert!(env.migrate(&manifest, &staged)?.applied.is_empty()); + let point = backup::create(root, &meta, Kind::Quick, Trigger::Manual, None, &|_| {})?; + backup::with_database(root, |db| { + db.query("INSERT INTO acore_characters.manager_profile_probe VALUES (22);")?; + Ok(()) + })?; + backup::restore_database(root, &meta, &point.id, "characters")?; + backup::with_database(root, |db| { + assert_eq!(db.query("SELECT COUNT(*) FROM acore_characters.manager_profile_probe;")?, "0"); + assert_eq!(db.clone().for_realm(Mode::Coa).query("SELECT value FROM acore_characters.manager_profile_probe;")?, "11"); + Ok(()) + })?; + println!("Both realms migrated once; shared auth migrated once; Wildcard restore left CoA intact."); + } + "view" => println!("{}", serde_json::to_string(&realms::view(root)?)?), + _ => return Err(Error::Invalid("Unknown fixture action".into())), + } + Ok(()) +} diff --git a/crates/coa-core/src/backup.rs b/crates/coa-core/src/backup.rs index 8e71b67..bdb41bf 100644 --- a/crates/coa-core/src/backup.rs +++ b/crates/coa-core/src/backup.rs @@ -63,6 +63,8 @@ pub struct RecoveryPoint { pub created_at: String, pub manager_version: String, pub components: Vec, + #[serde(default)] + pub realm: crate::realms::Mode, } fn backups_dir(meta: &Path) -> PathBuf { @@ -127,6 +129,8 @@ pub fn config_files(root: &Path) -> Vec { if root.join("RELEASE.json").is_file() { out.push("RELEASE.json".into()); } + walk(root, &root.join("Settings/realm-profiles"), &mut out); + if root.join("Settings/realm-profile.json").is_file() { out.push("Settings/realm-profile.json".into()); } out.sort(); out } @@ -191,16 +195,24 @@ pub fn create(root: &Path, meta: &Path, kind: Kind, trigger: Trigger, label: Opt let build = || -> Result> { let mut components = Vec::new(); - for name in wanted_databases(kind) { - let schema = db::schema_of(name)?; + let realm = crate::realms::state(root)?; + let mut databases: Vec<(String, &str)> = wanted_databases(kind).iter() + .map(|name| Ok((name.to_string(), realm.active.schema(name)?))).collect::>()?; + if realm.wildcard_created && kind != Kind::Config { + let other = if realm.active == crate::realms::Mode::Coa { crate::realms::Mode::Wildcard } else { crate::realms::Mode::Coa }; + databases.push((format!("{}-characters", other.name()), other.schema("characters")?)); + if kind == Kind::Full { databases.push((format!("{}-world", other.name()), other.schema("world")?)); } + } + for (name, schema) in databases { progress(&format!("Backing up {name} database")); let component = with_database(root, |db| { + let db = db.clone().for_realm(crate::realms::Mode::Coa); let size = db.schema_bytes(schema)?; fsx::require_space(&partial, size / 2 + 32 * 1024 * 1024)?; let tables = db.tables(schema)?.len(); let file = format!("{name}.sql.zst"); let (bytes, sha) = db.dump_to(schema, &partial.join(&file))?; - Ok(Component { name: (*name).into(), path: file, bytes, sha256: Some(sha), tables: Some(tables), files: None }) + Ok(Component { name, path: file, bytes, sha256: Some(sha), tables: Some(tables), files: None }) })?; components.push(component); } @@ -219,6 +231,7 @@ pub fn create(root: &Path, meta: &Path, kind: Kind, trigger: Trigger, label: Opt } }; let point = RecoveryPoint { + realm: crate::realms::state(root)?.active, schema: 1, id: id.clone(), kind, @@ -289,6 +302,9 @@ pub fn delete(meta: &Path, id: &str) -> Result<()> { /// Put configuration files back. Files the backup does not contain are left alone. A safety point is taken first. pub fn restore_configs(root: &Path, meta: &Path, id: &str) -> Result { let point = get(meta, id)?; + if point.realm != crate::realms::state(root)?.active { + return Err(Error::Invalid("Select the realm this backup belongs to before restoring it.".into())); + } let comp = point.components.iter().find(|c| c.name == "configs").ok_or_else(|| Error::Invalid("this backup has no configuration".into()))?; if !verify(meta, id)?.ok { return Err(Error::Invalid("This backup is damaged and cannot be restored.".into())); @@ -314,6 +330,9 @@ pub struct DbRestore { /// import into a staging schema, sanity-check it, then swap tables atomically and keep the old schema. pub fn restore_database(root: &Path, meta: &Path, id: &str, name: &str) -> Result { let point = get(meta, id)?; + if point.realm != crate::realms::state(root)?.active { + return Err(Error::Invalid("Select the realm this backup belongs to before restoring it.".into())); + } let comp = point.components.iter().find(|c| c.name == name && c.sha256.is_some()).ok_or_else(|| Error::Invalid(format!("this backup has no {name} database")))?; if !verify(meta, id)?.ok { return Err(Error::Invalid("This backup is damaged and cannot be restored.".into())); @@ -322,7 +341,7 @@ pub fn restore_database(root: &Path, meta: &Path, id: &str, name: &str) -> Resul if now.world.state != ServiceState::Stopped || now.auth.state != ServiceState::Stopped { return Err(Error::Invalid("Stop the server before restoring a database.".into())); } - let live = db::schema_of(name)?; + let live = if name.contains('-') { db::schema_of(name)? } else { point.realm.schema(name)? }; let expected_tables = comp.tables.unwrap_or(0); let dump = point_dir(meta, id)?.join(&comp.path); let stamp = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string(); @@ -331,6 +350,7 @@ pub fn restore_database(root: &Path, meta: &Path, id: &str, name: &str) -> Resul let safety = create(root, meta, Kind::Database, Trigger::BeforeRestore, Some(format!("before restoring {name} from {id}")), &|_| {})?; with_database(root, |db| { + let db = db.clone().for_realm(crate::realms::Mode::Coa); let staging = format!("{live}_restore_{stamp}"); let old = format!("{live}_before_restore_{stamp}"); if db.schema_exists(&staging)? || db.schema_exists(&old)? { diff --git a/crates/coa-core/src/config/mod.rs b/crates/coa-core/src/config/mod.rs index 5296d6a..4e680fe 100644 --- a/crates/coa-core/src/config/mod.rs +++ b/crates/coa-core/src/config/mod.rs @@ -436,6 +436,7 @@ fn quiet_debug_loggers(root: &Path) -> Result> { /// Apply validated `changes`. Nothing is written unless every change is valid; previous contents are snapshotted first. pub fn save(root: &Path, meta_dir: &Path, scope: Scope, changes: &BTreeMap) -> Result { + if scope == Scope::Bots { crate::realms::guard_module(root, "companions")?; } let raws = validate(root, scope, changes)?; let schema = scope.schema(); let t = targets(root, scope)?; diff --git a/crates/coa-core/src/db.rs b/crates/coa-core/src/db.rs index 83d0381..ff94438 100644 --- a/crates/coa-core/src/db.rs +++ b/crates/coa-core/src/db.rs @@ -44,6 +44,7 @@ pub struct Db { port: u16, user: &'static str, password: Secret, + realm: crate::realms::Mode, } #[derive(Deserialize)] @@ -67,6 +68,8 @@ fn no_window(cmd: &mut Command) { pub const SCHEMAS: [(&str, &str); 3] = [("characters", "acore_characters"), ("auth", "acore_auth"), ("world", "acore_world")]; pub fn schema_of(kind: &str) -> Result<&'static str> { + if let Some(kind) = kind.strip_prefix("wildcard-") { return crate::realms::Mode::Wildcard.schema(kind); } + if let Some(kind) = kind.strip_prefix("coa-") { return crate::realms::Mode::Coa.schema(kind); } SCHEMAS.iter().find(|(k, _)| *k == kind).map(|(_, s)| *s).ok_or_else(|| Error::Invalid(format!("unknown database {kind}"))) } @@ -87,7 +90,38 @@ impl Db { Account::Admin => ("root", creds.root_password), Account::App => ("acore", creds.app_password), }; - Ok(Db { bin, port: ports.mysql, user, password: Secret(password) }) + Ok(Db { bin, port: ports.mysql, user, password: Secret(password), realm: crate::realms::state(root)?.active }) + } + + pub fn for_realm(mut self, realm: crate::realms::Mode) -> Self { self.realm = realm; self } + + pub fn realm_schema<'a>(&self, name: &'a str) -> &'a str { + if self.realm == crate::realms::Mode::Wildcard { + match name { + "acore_world" => "acore_world_wildcard", + "acore_characters" => "acore_characters_wildcard", + _ => name, + } + } else { name } + } + + pub fn clone_structure(&self, source: &str, dest: &str, cache: &Path) -> Result<()> { + if !ident_ok(source) || !ident_ok(dest) { return Err(Error::Invalid("Invalid schema name.".into())); } + let path = cache.join("characters-structure.sql"); + self.dump_structure_to(source, &path)?; + self.run_sql_file(dest, &path)?; + std::fs::remove_file(path)?; + Ok(()) + } + + pub fn dump_structure_to(&self, source: &str, path: &Path) -> Result<()> { + if !ident_ok(source) { return Err(Error::Invalid("Invalid schema name.".into())); } + let mut c = self.command("mysqldump.exe"); + c.args(["--no-data", "--no-tablespaces", "--skip-comments", "--default-character-set=utf8mb4", source]); + c.stdout(Stdio::from(File::create(&path)?)); + let out = c.output()?; + if !out.status.success() { return Err(self.fail(&out.stderr)); } + Ok(()) } fn command(&self, tool: &str) -> Command { @@ -123,9 +157,10 @@ impl Db { pub fn query(&self, sql: &str) -> Result { let mut c = self.command("mysql.exe"); c.args(["--batch", "--skip-column-names", "--connect-timeout=5"]); + c.args(["--default-character-set=utf8mb4", "--max-allowed-packet=128M"]); c.stdin(Stdio::piped()); let mut child = c.spawn()?; - child.stdin.take().expect("piped").write_all(sql.as_bytes())?; + child.stdin.take().expect("piped").write_all(route_sql(sql, self.realm).as_bytes())?; let out = child.wait_with_output()?; if !out.status.success() { return Err(self.fail(&out.stderr)); @@ -134,6 +169,7 @@ impl Db { } pub fn schema_exists(&self, name: &str) -> Result { + let name = self.realm_schema(name); if !ident_ok(name) { return Err(Error::Invalid("bad schema name".into())); } @@ -141,6 +177,7 @@ impl Db { } pub fn tables(&self, schema: &str) -> Result> { + let schema = self.realm_schema(schema); if !ident_ok(schema) { return Err(Error::Invalid("bad schema name".into())); } @@ -150,6 +187,7 @@ impl Db { /// Bytes of data + index the schema occupies (used to estimate backup size). pub fn schema_bytes(&self, schema: &str) -> Result { + let schema = self.realm_schema(schema); if !ident_ok(schema) { return Err(Error::Invalid("bad schema name".into())); } @@ -159,6 +197,7 @@ impl Db { /// Stored routines / triggers / views make a table-level swap unsafe; count them. pub fn extra_objects(&self, schema: &str) -> Result { + let schema = self.realm_schema(schema); if !ident_ok(schema) { return Err(Error::Invalid("bad schema name".into())); } @@ -170,6 +209,7 @@ impl Db { /// Consistent dump of one schema, zstd-compressed to `out`. Returns (compressed bytes, sha256 of the file). pub fn dump_to(&self, schema: &str, out: &Path) -> Result<(u64, String)> { + let schema = self.realm_schema(schema); if !ident_ok(schema) { return Err(Error::Invalid("bad schema name".into())); } @@ -203,9 +243,15 @@ impl Db { /// Run a plain SQL file against `schema`; the first error aborts and is returned. pub fn run_sql_file(&self, schema: &str, file: &Path) -> Result<()> { + let schema = self.realm_schema(schema); if !ident_ok(schema) { return Err(Error::Invalid("bad schema name".into())); } + if self.realm == crate::realms::Mode::Wildcard { + let sql = std::fs::read_to_string(file)?; + self.query(&format!("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; USE `{schema}`;\n{sql}"))?; + return Ok(()); + } let mut c = self.command("mysql.exe"); c.args(["--default-character-set=utf8mb4", "--init-command=SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci", "--max-allowed-packet=128M"]); c.arg(schema); @@ -219,6 +265,7 @@ impl Db { /// Import a dump produced by `dump_to` into `schema` (which must already exist and be empty). pub fn import_from(&self, schema: &str, dump: &Path) -> Result<()> { + let schema = self.realm_schema(schema); if !ident_ok(schema) { return Err(Error::Invalid("bad schema name".into())); } @@ -278,3 +325,50 @@ pub fn write_console_credentials(root: &Path, password: &str) -> Result<()> { pub fn valid_identifier(name: &str) -> bool { ident_ok(name) } + +// Route only SQL identifiers. User names, values, comments and longer identifiers remain untouched. +fn route_sql(sql: &str, realm: crate::realms::Mode) -> String { + if realm == crate::realms::Mode::Coa { return sql.into(); } + let bytes = sql.as_bytes(); + let mut out = String::new(); + let mut i = 0; + while i < bytes.len() { + let start = i; + if matches!(bytes[i], b'\'' | b'"') { + let quote = bytes[i]; i += 1; + while i < bytes.len() { + if bytes[i] == b'\\' { i = (i + 2).min(bytes.len()); } + else if bytes[i] == quote { + i += 1; + if i < bytes.len() && bytes[i] == quote { i += 1; } else { break; } + } else { i += 1; } + } + out.push_str(&sql[start..i]); + } else if bytes[i] == b'#' || sql[i..].starts_with("--") || sql[i..].starts_with("/*") { + if sql[i..].starts_with("/*") { + i = sql[i + 2..].find("*/").map(|p| i + p + 4).unwrap_or(bytes.len()); + } else { i = sql[i..].find('\n').map(|p| i + p).unwrap_or(bytes.len()); } + out.push_str(&sql[start..i]); + } else if bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_' { + while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { i += 1; } + out.push_str(match &sql[start..i] { + "acore_world" => "acore_world_wildcard", + "acore_characters" => "acore_characters_wildcard", + other => other, + }); + } else { + let c = sql[i..].chars().next().unwrap(); out.push(c); i += c.len_utf8(); + } + } + out +} + +#[cfg(test)] +mod realm_tests { + use super::*; + #[test] + fn routes_schema_identifiers_without_rewriting_account_values() { + let sql = "SELECT * FROM `acore_characters`.characters WHERE name='acore_world' AND x=\"acore_characters\"; -- acore_world\nSELECT * FROM acore_world.items;"; + assert_eq!(route_sql(sql, crate::realms::Mode::Wildcard), "SELECT * FROM `acore_characters_wildcard`.characters WHERE name='acore_world' AND x=\"acore_characters\"; -- acore_world\nSELECT * FROM acore_world_wildcard.items;"); + } +} diff --git a/crates/coa-core/src/driver.rs b/crates/coa-core/src/driver.rs index 7b9f808..992df31 100644 --- a/crates/coa-core/src/driver.rs +++ b/crates/coa-core/src/driver.rs @@ -80,6 +80,17 @@ fn launcher(root: &Path) -> Result<(PathBuf, PathBuf)> { /// Run a launcher verb to completion (blocking; call from a worker thread). pub fn run(root: &Path, verb: Verb) -> Result { let root = fsx::canonicalize_lenient(root)?; + if verb == Verb::StartAll { + crate::realms::before_start(&root)?; + if root.join("Settings/realm-profile.json").exists() { + let db = crate::db::Db::from_repack(&root, crate::db::Account::Admin)?; + if !db.ping() { + let out = run(&root, Verb::StartMysql)?; + if !out.ok { return Ok(out); } + } + crate::realms::setup_realmlist(&root)?; + } + } let (python, script) = launcher(&root)?; let mut cmd = Command::new(&python); cmd.arg("-B").arg(&script).arg(verb.arg()).current_dir(&root).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); diff --git a/crates/coa-core/src/friends.rs b/crates/coa-core/src/friends.rs index ba4ee6c..ec9cccc 100644 --- a/crates/coa-core/src/friends.rs +++ b/crates/coa-core/src/friends.rs @@ -117,7 +117,8 @@ pub fn apply_realm_address(root: &Path, host: &str) -> Result<()> { return Err(Error::Invalid("That address is not valid.".into())); } let db = Db::from_repack(root, Account::Admin)?; - db.query(&format!("UPDATE acore_auth.realmlist SET address='{host}', localAddress='{host}' WHERE id=1;"))?; + let realm = crate::realms::state(root)?.active.realm_id(); + db.query(&format!("UPDATE acore_auth.realmlist SET address='{host}', localAddress='{host}' WHERE id={realm};"))?; Ok(()) } diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index e2bf73a..1ec07a9 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -29,6 +29,7 @@ pub mod ra; pub mod allsettings; pub mod modules; pub mod realmlist; +pub mod realms; pub mod registry; pub mod release; pub mod update; diff --git a/crates/coa-core/src/modules.rs b/crates/coa-core/src/modules.rs index 9e9f76b..2b118c3 100644 --- a/crates/coa-core/src/modules.rs +++ b/crates/coa-core/src/modules.rs @@ -97,6 +97,7 @@ pub struct ModuleView { pub icon: String, pub page: Option, pub hidden: bool, + pub compatibility: String, } pub fn list(root: &Path) -> Vec { @@ -116,6 +117,7 @@ fn conf_name(root: &Path, e: &Entry) -> String { } fn list_in(cat: &[Entry], root: &Path) -> Vec { + let wildcard = crate::realms::state(root).map(|s| s.active == crate::realms::Mode::Wildcard).unwrap_or(true); cat.iter() .cloned() .map(|e| { @@ -126,14 +128,16 @@ fn list_in(cat: &[Entry], root: &Path) -> Vec { // the active file decides; without one the documented default is what the server will use let enabled = !e.switchable || read(&conf).or_else(|| read(&dist)).and_then(|c| c.get(&e.enable_key).map(truthy)).unwrap_or(e.default_on); + let blocked = wildcard && matches!(e.id.as_str(), "companions" | "playerbots"); ModuleView { + compatibility: if blocked { "unsupported" } else if wildcard && e.id != "client-compat" { "experimental" } else { "compatible" }.into(), id: e.id, name: e.name, description: e.description, repo: e.repo, installed, - enabled, - switchable: e.switchable, + enabled: enabled && !blocked, + switchable: e.switchable && !blocked, status: e.status, icon: e.icon, page: e.page, @@ -164,6 +168,7 @@ fn backup(meta: &Path, conf: &Path) -> Result<()> { } pub fn set_enabled(root: &Path, meta: &Path, id: &str, on: bool) -> Result<()> { + if on { crate::realms::guard_module(root, id)?; } set_enabled_in(&catalog(), root, meta, id, on) } @@ -217,6 +222,7 @@ fn settings_in(cat: &[Entry], root: &Path, id: &str) -> Result> { /// Change values of settings the module already has. A value is one line of at most 500 characters. pub fn save_settings(root: &Path, meta: &Path, id: &str, changes: &BTreeMap) -> Result> { + crate::realms::guard_module(root, id)?; save_settings_in(&catalog(), root, meta, id, changes) } diff --git a/crates/coa-core/src/realms.rs b/crates/coa-core/src/realms.rs new file mode 100644 index 0000000..db4c283 --- /dev/null +++ b/crates/coa-core/src/realms.rs @@ -0,0 +1,371 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::config::parser::ConfFile; +use crate::db::{Account, Db}; +use crate::error::{Error, Result}; +use crate::{fsx, layout, process}; + +const STATE: &str = "Settings/realm-profile.json"; +const JOURNAL: &str = "Settings/realm-switch.json"; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Mode { + #[default] + Coa, + Wildcard, +} + +impl Mode { + pub fn name(self) -> &'static str { + match self { Self::Coa => "coa", Self::Wildcard => "wildcard" } + } + pub fn realm_id(self) -> u32 { if self == Self::Coa { 1 } else { 2 } } + pub fn schema(self, kind: &str) -> Result<&'static str> { + match (self, kind) { + (_, "auth") => Ok("acore_auth"), + (Self::Coa, "characters") => Ok("acore_characters"), + (Self::Coa, "world") => Ok("acore_world"), + (Self::Wildcard, "characters") => Ok("acore_characters_wildcard"), + (Self::Wildcard, "world") => Ok("acore_world_wildcard"), + _ => Err(Error::Invalid("Unknown realm database.".into())), + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RealmState { + pub active: Mode, + pub wildcard_created: bool, +} + +#[derive(Debug, Serialize)] +pub struct View { + pub active: Mode, + pub wildcard_created: bool, + pub supported: bool, + pub recovery_pending: bool, +} + +pub fn state(root: &Path) -> Result { + if !root.join(STATE).exists() { return Ok(RealmState::default()); } + fsx::read_json(&root.join(STATE)) +} + +pub fn view(root: &Path) -> Result { + let s = state(root)?; + // Inspect the binary itself; templates or an old log cannot prove installed support. + let supported = fs::read(root.join("Core/worldserver.exe"))? + .windows(b"Wildcard synergy settings".len()).any(|w| w == b"Wildcard synergy settings"); + Ok(View { active: s.active, wildcard_created: s.wildcard_created, supported, recovery_pending: root.join(JOURNAL).exists() }) +} + +pub fn guard_module(root: &Path, id: &str) -> Result<()> { + if state(root)?.active == Mode::Wildcard && matches!(id, "companions" | "playerbots") { + return Err(Error::Invalid("CoA companion class mechanics have not been validated for Wildcard. This module is unavailable on this realm.".into())); + } + Ok(()) +} + +type Files = BTreeMap>; + +fn configs(root: &Path) -> Result { + let mut files = Files::new(); + for rel in crate::backup::config_files(root) { + if rel.starts_with("Core/configs/") && rel.ends_with(".conf") + || rel.starts_with("Settings/") && rel.ends_with(".template") { + files.insert(rel.clone(), fs::read(fsx::safe_join(root, &rel)?)?); + } + } + Ok(files) +} + +fn snapshot(root: &Path, mode: Mode) -> std::path::PathBuf { + root.join(format!("Settings/realm-profiles/{}.json", mode.name())) +} + +fn write_configs(root: &Path, files: &Files) -> Result<()> { + for rel in configs(root)?.keys() { + if !files.contains_key(rel) { fs::remove_file(fsx::safe_join(root, rel)?)?; } + } + for (rel, bytes) in files { + if !(rel.starts_with("Core/configs/") && rel.ends_with(".conf") + || rel.starts_with("Settings/") && rel.ends_with(".template")) { + return Err(Error::Invalid("Invalid realm configuration path.".into())); + } + fsx::atomic_write(&fsx::ensure_within(root, &fsx::safe_join(root, rel)?)?, bytes)?; + } + Ok(()) +} + +fn set(files: &mut Files, path: &str, key: &str, value: &str) -> Result<()> { + let mut conf = ConfFile::parse_bytes(files.entry(path.into()).or_insert_with(|| b"[worldserver]\n".to_vec()))?; + conf.set(key, value, &[]); + files.insert(path.into(), conf.to_text().into_bytes()); + Ok(()) +} + +fn wildcard_configs(mut files: Files) -> Result { + for (key, value) in [ + ("CoA.ClassModel", "\"hero\""), ("CoA.GameModeMask", "64"), + ("CoA.MapClass10ToWarrior", "0"), ("CoA.RealmType", "\"live seasonal\""), + ("CoA.ClientBooleanConfigs", "\"CONFIG_LEGACY_CHARACTER_ADVANCEMENT_ENABLED=0\""), + ] { set(&mut files, "Core/configs/modules/coa.conf", key, value)?; } + set(&mut files, "Core/configs/modules/mod-coa-challenges.conf", "CoAChallenges.GameModes.Realm", "WildCard")?; + set(&mut files, "Core/configs/modules/mod-coa-challenges.conf", "CoAChallenges.GameModes.Enable", "1")?; + for (key, value) in [("ChancePercent", "65"), ("LinkWeight", "3"), ("RelatedWeight", "2"), ("SpecTagWeight", "2"), ("SchoolTagWeight", "1"), ("TooltipWeight", "3"), ("TalentsNeedTarget", "1"), ("LogRolls", "1")] { + set(&mut files, "Core/configs/modules/wildcard.conf", &format!("Wildcard.Synergy.{key}"), value)?; + } + for path in ["Settings/worldserver.conf.template", "Core/configs/worldserver.conf"] { + if files.contains_key(path) { + for (key, value) in [("RealmID", "2"), ("AlwaysMaxSkillForLevel", "1"), ("PlayerStart.CustomSpells", "1")] { + set(&mut files, path, key, value)?; + } + } + } + for (key, value) in [("CoaBots.Enable", "0"), ("CoaBots.AutoLoginOnStartup", "0")] { + set(&mut files, "Core/configs/modules/mod_coa_playerbots.conf", key, value)?; + } + Ok(files) +} + +#[derive(Serialize, Deserialize)] +struct Journal { before: RealmState, files: Files } + +fn require_world_stopped(root: &Path) -> Result<()> { + let observed = process::observe(root, &layout::read_ports(root)); + if observed.world.state != process::ServiceState::Stopped || observed.auth.state != process::ServiceState::Stopped { + return Err(Error::Invalid("Stop the server before switching realms. Your characters will be saved.".into())); + } + Ok(()) +} + +pub fn recover(root: &Path) -> Result<()> { + if !root.join(JOURNAL).exists() { return Ok(()); } + require_world_stopped(root)?; + let journal: Journal = fsx::read_json(&root.join(JOURNAL))?; + write_configs(root, &journal.files)?; + fsx::atomic_write_json(&root.join(STATE), &journal.before)?; + fs::remove_file(root.join(JOURNAL))?; + Ok(()) +} + +fn create_databases(root: &Path) -> Result<()> { + crate::backup::with_database(root, |_| { + let db = Db::from_repack(root, Account::Admin)?.for_realm(Mode::Coa); + if db.query("SELECT COUNT(*) FROM acore_auth.realmlist WHERE id=2;")? != "0" { + return Err(Error::Invalid("Realm ID 2 is already in use. Its settings were left intact.".into())); + } + for kind in ["world", "characters"] { + let source = Mode::Coa.schema(kind)?; + let dest = Mode::Wildcard.schema(kind)?; + if db.schema_exists(dest)? { + return Err(Error::Invalid(format!("Database {dest} already exists. It was left intact; inspect it before creating a realm."))); + } + if db.extra_objects(source)? != 0 { + return Err(Error::Invalid("Realm creation does not support databases with routines, triggers or views.".into())); + } + } + for (schema, tables) in [ + ("acore_world", &["ascension_wildcard_boss_marks", "ascension_wildcard_tooltip_links"][..]), + ("acore_characters", &["coa_wildcard_skill_card", "coa_wildcard_skill_card_pending", "coa_wildcard_skill_card_account", "coa_wildcard_skill_card_purchase", "coa_wildcard_specialization_cache"][..]), + ] { + let present = db.tables(schema)?; + if tables.iter().any(|table| !present.iter().any(|p| p == table)) { + return Err(Error::Invalid("Wildcard database migrations are missing. Update the server database before creating this realm.".into())); + } + } + let cache = root.join("Settings/realm-profiles/staging"); + fs::create_dir_all(&cache)?; + fsx::require_space(&cache, db.schema_bytes("acore_world")?.saturating_mul(2) + 512 * 1024 * 1024)?; + let dump = cache.join("world.sql.zst"); + db.dump_to("acore_world", &dump)?; + let stage_world = format!("coa_realm_world_{}", uuid::Uuid::new_v4().simple()); + let stage_chars = format!("coa_realm_chars_{}", uuid::Uuid::new_v4().simple()); + db.query(&format!("CREATE DATABASE `{stage_world}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE DATABASE `{stage_chars}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"))?; + db.import_from(&stage_world, &dump)?; + db.clone_structure("acore_characters", &stage_chars, &cache)?; + for table in ["active_arena_season", "addons", "updates", "updates_include", "warden_action"] { + db.query(&format!("INSERT INTO `{stage_chars}`.`{table}` SELECT * FROM acore_characters.`{table}`;"))?; + } + // The newly empty realm inherits schema migration history, never the owner's characters or cards. + if db.tables("acore_world")?.iter().any(|t| t == "coa_manager_migrations") { + db.query(&format!("CREATE TABLE IF NOT EXISTS `{stage_world}`.coa_manager_migrations LIKE acore_world.coa_manager_migrations;"))?; + } + db.query("CREATE DATABASE acore_world_wildcard CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE DATABASE acore_characters_wildcard CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;")?; + let mut renames = Vec::new(); + for (stage, dest) in [(&stage_world, "acore_world_wildcard"), (&stage_chars, "acore_characters_wildcard")] { + for table in db.tables(stage)? { + if !crate::db::valid_identifier(&table) { return Err(Error::Invalid("Unsupported table name.".into())); } + renames.push(format!("`{stage}`.`{table}` TO `{dest}`.`{table}`")); + } + } + db.query(&format!("RENAME TABLE {};", renames.join(", ")))?; + for host in db.query("SELECT host FROM mysql.user WHERE user='acore';")?.lines() { + let host = hex::encode(host.as_bytes()); + // Host names are trusted database data, but still quote them through a hex SQL literal. + db.query(&format!("SET @host=CONVERT(UNHEX('{host}') USING utf8mb4); SET @grant=CONCAT('GRANT ALL ON acore_world_wildcard.* TO ', QUOTE('acore'), '@', QUOTE(@host)); PREPARE realm_grant FROM @grant; EXECUTE realm_grant; DEALLOCATE PREPARE realm_grant; SET @grant=CONCAT('GRANT ALL ON acore_characters_wildcard.* TO ', QUOTE('acore'), '@', QUOTE(@host)); PREPARE realm_grant FROM @grant; EXECUTE realm_grant; DEALLOCATE PREPARE realm_grant;"))?; + } + db.query(&format!("DROP DATABASE `{stage_world}`; DROP DATABASE `{stage_chars}`;"))?; + fs::remove_file(&dump)?; + Ok(()) + }) +} + +pub fn select(root: &Path, mode: Mode) -> Result { + require_world_stopped(root)?; + recover(root)?; + let mut s = state(root)?; + if mode == s.active { return view(root); } + if mode == Mode::Wildcard && !view(root)?.supported { + return Err(Error::Invalid("Update the server to a build with Wildcard support first.".into())); + } + // Verify launcher support before creating databases or changing the selected realm. + prepare_launcher(root)?; + let before = configs(root)?; + fsx::atomic_write_json(&snapshot(root, s.active), &before)?; + if mode == Mode::Wildcard && !s.wildcard_created { + fsx::atomic_write_json(&snapshot(root, mode), &wildcard_configs(before.clone())?)?; + create_databases(root)?; + s.wildcard_created = true; + fsx::atomic_write_json(&root.join(STATE), &s)?; + } + let target: Files = fsx::read_json(&snapshot(root, mode))?; + // The launcher and batch files share this lock; recheck after the potentially long database copy. + fs::create_dir_all(root.join(".state"))?; + let lock = fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(root.join(".state/control.lock"))?; + fs4::fs_std::FileExt::try_lock_exclusive(&lock).map_err(|_| Error::Invalid("Another start/stop action is in progress.".into()))?; + require_world_stopped(root)?; + let original = state(root)?; + fsx::atomic_write_json(&root.join(JOURNAL), &Journal { before: original, files: before })?; + let result = (|| { + write_configs(root, &target)?; + s.active = mode; + fsx::atomic_write_json(&root.join(STATE), &s)?; + Ok(()) + })(); + if let Err(e) = result { drop(lock); recover(root)?; return Err(e); } + fs::remove_file(root.join(JOURNAL))?; + view(root) +} + +/// Adapt the bundled launcher's canonical schema and realm choices without changing its supervision logic. +pub fn prepare_launcher(root: &Path) -> Result<()> { + let path = root.join("Scripts/manage.py"); + let source = fs::read_to_string(&path)?; + let patched = patch_launcher(&source)?; + if patched != source { fsx::atomic_write(&path, patched.as_bytes())?; } + Ok(()) +} + +fn patch_launcher(source: &str) -> Result { + if source.contains("# coa-manager-realm-profiles-v1") { return Ok(source.into()); } + let replacements = [ + ("(\"WorldDatabaseInfo\", \"acore_world\")", "(\"WorldDatabaseInfo\", \"acore_world_wildcard\" if coa_realm() == 'wildcard' else \"acore_world\")"), + ("(\"CharacterDatabaseInfo\", \"acore_characters\")", "(\"CharacterDatabaseInfo\", \"acore_characters_wildcard\" if coa_realm() == 'wildcard' else \"acore_characters\")"), + ("SET name='AzerothCore',address=", "SET name='{coa_realm_name()}',address="), + ("WHERE id=1;", "WHERE id={coa_realm_id()};"), + ("mysql(\"UPDATE acore_auth.realmlist SET flag=0", "mysql(f\"UPDATE acore_auth.realmlist SET flag=0"), + ]; + let mut out = source.to_string(); + for (from, to) in replacements { + if !out.contains(from) { return Err(Error::Invalid("This launcher does not support realm profiles. Update the server package first.".into())); } + out = out.replace(from, to); + } + let insert = "# coa-manager-realm-profiles-v1\ndef coa_realm():\n path = ROOT / 'Settings/realm-profile.json'\n return json.loads(path.read_text(encoding='utf-8')).get('active', 'coa') if path.exists() else 'coa'\n\ndef coa_realm_id():\n return 2 if coa_realm() == 'wildcard' else 1\n\ndef coa_realm_name():\n return 'Wildcard' if coa_realm() == 'wildcard' else 'Conquest of Azeroth'\n\n"; + // Insert before the entry point so ROOT, json and functions are all defined when called. + let marker = "if __name__ == \"__main__\":"; + if !out.contains(marker) { return Err(Error::Invalid("Unrecognised server launcher entry point.".into())); } + Ok(out.replace(marker, &format!("{insert}{marker}"))) +} + +pub fn before_start(root: &Path) -> Result<()> { + recover(root)?; + let s = state(root)?; + if !root.join(STATE).exists() { return Ok(()); } + prepare_launcher(root)?; + if s.active == Mode::Wildcard { + if !view(root)?.supported { return Err(Error::Invalid("The installed server build does not support Wildcard.".into())); } + let path = root.join("Core/configs/modules/mod_coa_playerbots.conf"); + let mut conf = ConfFile::parse_bytes(&fs::read(&path)?)?; + conf.set("CoaBots.Enable", "0", &[]); + fsx::atomic_write(&path, conf.to_text().as_bytes())?; + } + Ok(()) +} + +pub fn setup_realmlist(root: &Path) -> Result<()> { + let s = state(root)?; + if !root.join(STATE).exists() || !s.wildcard_created { return Ok(()); } + let db = Db::from_repack(root, Account::Admin)?; + let port = layout::read_ports(root).world; + db.query(&format!("INSERT INTO acore_auth.realmlist (id,name,address,localAddress,localSubnetMask,port,icon,flag,timezone,gamebuild) SELECT 2,'Wildcard',address,localAddress,localSubnetMask,{port},icon,2,timezone,gamebuild FROM acore_auth.realmlist WHERE id=1 ON DUPLICATE KEY UPDATE port={port}; UPDATE acore_auth.realmlist SET flag=2 WHERE id IN (1,2) AND id<>{};", s.active.realm_id()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fresh_wildcard_disables_bots_and_preserves_scaling() { + let mut f = Files::new(); + f.insert("Core/configs/modules/mod-coa-content-scaling.conf".into(), b"[worldserver]\nCoAContentScaling.Enable = 1\n".to_vec()); + f.insert("Settings/worldserver.conf.template".into(), b"[worldserver]\nRealmID = 1\n".to_vec()); + let w = wildcard_configs(f.clone()).unwrap(); + assert_eq!(w["Core/configs/modules/mod-coa-content-scaling.conf"], f["Core/configs/modules/mod-coa-content-scaling.conf"]); + let c = ConfFile::parse_bytes(&w["Core/configs/modules/mod_coa_playerbots.conf"]).unwrap(); + assert_eq!(c.get("CoaBots.Enable"), Some("0")); + assert_eq!(ConfFile::parse_bytes(&w["Settings/worldserver.conf.template"]).unwrap().get("RealmID"), Some("2")); + } + + #[test] + fn corrupt_state_is_not_silently_treated_as_coa() { + let t = tempfile::tempdir().unwrap(); + fs::create_dir(t.path().join("Settings")).unwrap(); + fs::write(t.path().join(STATE), "broken").unwrap(); + assert!(state(t.path()).is_err()); + } + + #[test] + fn recovery_restores_original_settings() { + let t = tempfile::tempdir().unwrap(); + let mut files = Files::new(); + files.insert("Core/configs/modules/coa.conf".into(), b"CoA.ClassModel = coa\n".to_vec()); + fsx::atomic_write_json(&t.path().join(JOURNAL), &Journal { before: RealmState::default(), files }).unwrap(); + recover(t.path()).unwrap(); + assert_eq!(state(t.path()).unwrap().active, Mode::Coa); + assert_eq!(fs::read_to_string(t.path().join("Core/configs/modules/coa.conf")).unwrap(), "CoA.ClassModel = coa\n"); + assert!(!t.path().join(JOURNAL).exists()); + } + + #[test] + fn reverting_to_coa_removes_wildcard_only_config_files() { + let t = tempfile::tempdir().unwrap(); + let mut coa = Files::new(); + coa.insert("Core/configs/modules/coa.conf".into(), b"[worldserver]\nCoA.ClassModel = coa\n".to_vec()); + let wildcard = wildcard_configs(coa.clone()).unwrap(); + write_configs(t.path(), &wildcard).unwrap(); + write_configs(t.path(), &coa).unwrap(); + assert!(!t.path().join("Core/configs/modules/mod-coa-challenges.conf").exists()); + assert_eq!(configs(t.path()).unwrap(), coa); + } + + #[test] + fn wildcard_blocks_bot_settings_and_enable_calls() { + let t = tempfile::tempdir().unwrap(); + fsx::atomic_write_json(&t.path().join(STATE), &RealmState { active: Mode::Wildcard, wildcard_created: true }).unwrap(); + assert!(crate::modules::set_enabled(t.path(), t.path(), "companions", true).is_err()); + assert!(crate::config::save(t.path(), t.path(), crate::config::Scope::Bots, &BTreeMap::new()).is_err()); + assert!(guard_module(t.path(), "content-scaling").is_ok()); + } + + #[test] + fn unknown_launcher_is_refused_without_partial_patching() { + assert!(patch_launcher("print('custom launcher')").is_err()); + } +} diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index d1fe9fa..5028c10 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -476,9 +476,24 @@ impl Env for RepackEnv<'_> { fn migrate(&self, manifest: &Manifest, staged: &Path) -> Result { let (root, meta) = (self.root, self.meta_dir); crate::backup::with_database(root, |db| { - crate::migrations::apply_pending(db, &manifest.migrations, staged, &|| { - Ok(crate::backup::create(root, meta, crate::backup::Kind::Database, crate::backup::Trigger::BeforeMigration, None, &|_| {})?.id) - }) + let realms = crate::realms::state(root)?; + let mut result = crate::migrations::apply_pending(db, &manifest.migrations, staged, &|| { + Ok(crate::backup::create(root, meta, crate::backup::Kind::Full, crate::backup::Trigger::BeforeMigration, None, &|_| {})?.id) + })?; + if realms.wildcard_created && result.failed.is_none() { + let other = if realms.active == crate::realms::Mode::Coa { crate::realms::Mode::Wildcard } else { crate::realms::Mode::Coa }; + let other_db = db.clone().for_realm(other); + let shared_snapshot = result.snapshot.clone(); + let migrations: Vec<_> = manifest.migrations.iter().filter(|m| m.db != "auth").cloned().collect(); + let extra = crate::migrations::apply_pending(&other_db, &migrations, staged, &|| { + if let Some(id) = &shared_snapshot { return Ok(id.clone()); } + Ok(crate::backup::create(root, meta, crate::backup::Kind::Full, crate::backup::Trigger::BeforeMigration, None, &|_| {})?.id) + })?; + result.applied.extend(extra.applied); + result.failed = extra.failed; + if result.snapshot.is_none() { result.snapshot = extra.snapshot; } + } + Ok(result) }) } diff --git a/docs/WILDCARD.md b/docs/WILDCARD.md new file mode 100644 index 0000000..aa4bc72 --- /dev/null +++ b/docs/WILDCARD.md @@ -0,0 +1,42 @@ +# Realm profiles + +Overview offers CoA and Wildcard. The installed worldserver must contain Wildcard support and the database must +already have its Wildcard migrations. The Manager refuses an unsupported binary, an occupied realm ID 2 or an +existing destination database rather than overwrite them. + +The first switch copies the CoA world into `acore_world_wildcard` and creates `acore_characters_wildcard` from +the character schema and neutral server seed tables. Existing characters, card collections and progress are not +copied. Accounts remain in shared `acore_auth`. Realm IDs 1 and 2 share the world port; only the selected world +runs, and the inactive realm is listed offline. A switch while running stops the server gracefully and restarts it. + +Configuration profiles are saved under `Settings/realm-profiles`. An interrupted switch can restore its previous +configuration from the journal. CoA companion bots cannot be enabled on Wildcard. Content Scaling and the other +optional modules retain their settings but show experimental compatibility; class mechanics need separate gameplay +validation. Switching back restores the CoA module settings. + +Quick/database backups include both character databases and shared auth. Full backups also include both worlds. +Recovery points identify their active realm; select that realm before restoring them. Server migrations apply to +both realms, with auth applied once. The server updater can replace its launcher: the Manager reapplies the realm +adapter before the next start. + +![Wildcard overview](screenshots/wildcard-demo/overview.jpg) + +![Module compatibility](screenshots/wildcard-demo/modules.jpg) + +## Validation for v0.5.0 + +- `cargo test --workspace`: 178 core tests passed; command layer and examples compiled. +- `npm run build`, translation key checks and changelog validation passed. +- Native disposable fixture on offset ports: fresh Wildcard creation and startup, CoA startup after switching back, + separate probe values and restored CoA configuration. A second fresh creation verified the arena-season seed fix. +- Full backup verified both worlds, both character databases, shared auth and configurations. +- Native migration probe applied once to each realm and once to auth; repeating it did nothing. +- Restoring Wildcard characters removed a fixture-only change and preserved the CoA probe value. +- Core gameplay wrapper: `VERIFY ALL: PASSED`, gameplay stage only, real clock. First login (7 assertions), + level rolls/reroll (31), skill card rolls (13), stat path (12). The level-roll and skill-card scenarios passed on isolated + reruns after failing in the shared batch; the wrapper records two batch-sensitive cases. +- Browser demo verified active profile, hidden Bots navigation and module compatibility labels. Screenshots show + mock IPC; native checks above exercised the actual backend and server. + +The gameplay harness required fixture-only synchronization of already applied migration history by verified SQL +hashes and its normal Windows module config path. Owned server files and databases were not modified. diff --git a/docs/screenshots/wildcard-demo/modules.jpg b/docs/screenshots/wildcard-demo/modules.jpg new file mode 100644 index 0000000..ebe4fb0 Binary files /dev/null and b/docs/screenshots/wildcard-demo/modules.jpg differ diff --git a/docs/screenshots/wildcard-demo/overview.jpg b/docs/screenshots/wildcard-demo/overview.jpg new file mode 100644 index 0000000..d788d9a Binary files /dev/null and b/docs/screenshots/wildcard-demo/overview.jpg differ diff --git a/package-lock.json b/package-lock.json index 648c60b..b34297d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coa-server-manager", - "version": "0.1.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coa-server-manager", - "version": "0.1.0", + "version": "0.5.0", "dependencies": { "@tauri-apps/api": "^2.12.0", "@tauri-apps/plugin-dialog": "^2.8.0", diff --git a/package.json b/package.json index 62b888a..e4e6958 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coa-server-manager", "private": true, - "version": "0.4.0", + "version": "0.5.0", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ac98b30..9e0d47e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -189,6 +189,7 @@ async fn run_verb(state: &AppState, id: String, verb: Verb) -> std::result::Resu #[tauri::command] async fn start_server(state: State<'_, AppState>, id: String) -> std::result::Result { + let _guard = BusyGuard::acquire(&state, &id)?; // Servers installed before module configs were created automatically get them now (missing files only). Imported // servers only get the module files they lack (see below); nothing they have is changed. if let Ok(root) = path_of(&state, &id) { @@ -209,12 +210,14 @@ async fn start_server(state: State<'_, AppState>, id: String) -> std::result::Re }) .await; } - let out = run_verb(&state, id.clone(), Verb::StartAll).await?; + let root = path_of(&state, &id)?; + let out = blocking(move || driver::run(&root, Verb::StartAll)).await?; if out.ok { if let Ok(root) = path_of(&state, &id) { // Companions requested while the server was stopped are created now (once; a failure is only logged). let r = root.clone(); let _ = tauri::async_runtime::spawn_blocking(move || -> Result<()> { + if coa_core::realms::guard_module(&r, "companions").is_err() { return Ok(()); } let pending = meta_dir(&r)?.join("companions.pending.json"); if let Ok(v) = coa_core::fsx::read_json::(&pending) { let _ = std::fs::remove_file(&pending); @@ -278,6 +281,7 @@ async fn save_settings( changes: BTreeMap, ) -> std::result::Result { let root = path_of(&state, &id)?; + let _guard = BusyGuard::acquire(&state, &id)?; blocking(move || { let meta = meta_dir(&root)?; config::save(&root, &meta, scope, &changes) @@ -512,9 +516,35 @@ async fn modules_list(state: State<'_, AppState>, id: String) -> std::result::Re blocking(move || Ok(coa_core::modules::list(&root))).await } +#[tauri::command] +async fn realm_profiles(state: State<'_, AppState>, id: String) -> std::result::Result { + let root = path_of(&state, &id)?; + blocking(move || coa_core::realms::view(&root)).await +} + +#[tauri::command] +async fn realm_select(state: State<'_, AppState>, id: String, mode: coa_core::realms::Mode, restart: bool) -> std::result::Result { + let root = path_of(&state, &id)?; + let _guard = BusyGuard::acquire(&state, &id)?; + let result = blocking(move || { + if restart { + let out = driver::run(&root, Verb::StopAll)?; + if !out.ok { return Err(Error::Invalid(out.output)); } + } + let view = coa_core::realms::select(&root, mode)?; + if restart { + let out = driver::run(&root, Verb::StartAll)?; + if !out.ok { return Err(Error::Invalid(format!("Realm selected, but startup failed: {}", out.output))); } + } + Ok(view) + }).await; + result +} + #[tauri::command] async fn module_set_enabled(state: State<'_, AppState>, id: String, module: String, enabled: bool) -> std::result::Result<(), UiError> { let root = path_of(&state, &id)?; + let _guard = BusyGuard::acquire(&state, &id)?; blocking(move || { let (dir, _) = install_meta(&root)?; coa_core::modules::set_enabled(&root, &dir, &module, enabled) @@ -531,6 +561,7 @@ async fn module_settings(state: State<'_, AppState>, id: String, module: String) #[tauri::command] async fn module_save_settings(state: State<'_, AppState>, id: String, module: String, changes: BTreeMap) -> std::result::Result, UiError> { let root = path_of(&state, &id)?; + let _guard = BusyGuard::acquire(&state, &id)?; blocking(move || { let (dir, _) = install_meta(&root)?; coa_core::modules::save_settings(&root, &dir, &module, &changes) @@ -547,6 +578,7 @@ async fn all_settings(state: State<'_, AppState>, id: String) -> std::result::Re #[tauri::command] async fn all_settings_save(state: State<'_, AppState>, id: String, changes: BTreeMap) -> std::result::Result, UiError> { let root = path_of(&state, &id)?; + let _guard = BusyGuard::acquire(&state, &id)?; blocking(move || { let (dir, _) = install_meta(&root)?; coa_core::allsettings::save(&root, &dir, &changes) @@ -1395,6 +1427,8 @@ pub fn run() { realmlist_delete, realmlist_activate, modules_list, + realm_profiles, + realm_select, module_set_enabled, module_settings, module_save_settings, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6fd761b..219b2a5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CoA Server Manager", - "version": "0.4.0", + "version": "0.5.0", "identifier": "com.coa.servermanager", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/dev/mock.ts b/src/dev/mock.ts index 71ce41f..ee96a8b 100644 --- a/src/dev/mock.ts +++ b/src/dev/mock.ts @@ -6,9 +6,10 @@ import serverPresets from "../../schemas/presets/server.json"; export {}; (() => { - let added = false, running = false; + let added = new URLSearchParams(location.search).has("demo"), running = false; + let realm: "coa" | "wildcard" = "coa", wildcardCreated = false; const modState: Record = { "companions": true, "content-scaling": true, "client-compat": true, "tbc-content": true, "wotlk-content": true, "npc-enchanter": true }; - const report = {path:"C:\games\CoA-Repack",classification:"healthy",items:[ + const report = {path:"C:\\games\\CoA-Repack",classification:"healthy",items:[ {key:"worldserver",label:"World server",status:"found",detail:null}, {key:"authserver",label:"Auth server",status:"found",detail:null}, {key:"database_content",label:"Auth / Characters / World databases",status:"found",detail:"acore_auth, acore_characters, acore_world"}, @@ -21,13 +22,15 @@ export {}; (window as any).__TAURI_INTERNALS__ = { transformCallback: (cb: unknown)=>cb, unregisterCallback(){}, convertFileSrc:(x: unknown)=>x, invoke: async (cmd: string, args?: any)=>{ + if(cmd==="realm_profiles") return {active:realm,wildcard_created:wildcardCreated,supported:true,recovery_pending:false}; + if(cmd==="realm_select"){await new Promise(r=>setTimeout(r,600));realm=args.mode;wildcardCreated ||= realm==="wildcard";modState.companions=realm==="coa";return {active:realm,wildcard_created:wildcardCreated,supported:true,recovery_pending:false};} if(cmd==="list_servers") return added?[{id:"1",name:"CoA-Repack",path:report.path}]:[]; if(cmd==="scan_server") return report; if(cmd==="add_server"){added=true;return {id:"1",name:"CoA-Repack",path:report.path};} if(cmd==="server_status"){const s=running?"running":"stopped";return {observed:{mysql:svc("mysql",3307,s),auth:svc("auth",3724,s),world:svc("world",8085,s)},busy:false,path_exists:true};} if(cmd==="start_server"){await new Promise((r: any)=>setTimeout(r,1500));running=true;return {ok:true,exit_code:0,code:null,human:null,output:""};} if(cmd==="stop_server"){await new Promise((r: any)=>setTimeout(r,1500));running=false;return {ok:true,exit_code:0,code:null,human:null,output:""};} - if(cmd.startsWith("plugin:dialog")) return "C:\games\CoA-Repack"; + if(cmd.startsWith("plugin:dialog")) return "C:\\games\\CoA-Repack"; if(cmd==="get_settings"){const sc=args?.scope==="server"?serverSchema:botsSchema;return {scope:args?.scope,categories:sc.categories,settings:(sc.settings as any[]).map(x=>({...x,options:x.options??[],value:x.default,is_default:true,present:true,problem:null,drift:false})),unknown_keys:3,drift_keys:[],files:[]};} if(cmd==="list_presets"){const pr=args?.scope==="server"?serverPresets:botsPresets;return (pr.presets as any[]).map(p=>({id:p.id,title:p.title,description:p.description}));} if(cmd==="preview_preset"){const a=args;const sc=a.scope==="server"?serverSchema:botsSchema;const pr=(a.scope==="server"?serverPresets:botsPresets).presets as any[];const p=pr.find(x=>x.id===a.preset)??{id:a.preset??"defaults",title:"Recommended defaults",description:"Every setting returns to its default value."};const st=(sc.settings as any[]).filter(x=>x.type==="int"||x.type==="float"||x.type==="enum").slice(0,3);return {id:p.id,title:p.title,description:p.description,changes:st.map(x=>({key:x.key,title:x.title,from:x.default,to:x.type==="enum"?(x.options?.[1]?.value??x.default):(Number(x.default)||1)*2,dangerous:false}))};} @@ -56,7 +59,7 @@ export {}; if(cmd==="realmlist_save"){const w=window as any;const rl=w.__rl;if(args.profileId){const p=rl.profiles.find((x: any)=>x.id===args.profileId);p.name=args.name;p.data=args.data;return p;}if(rl.profiles.some((x: any)=>x.name.toLowerCase()===String(args.name).toLowerCase()))throw {human:{code:"unknown",title:"",message:"",actions:[]},technical:"A realmlist with that name already exists."};const p={id:String(args.name).toLowerCase().replace(/[^a-z0-9]+/g,"-"),name:args.name,data:(/\s/.test(args.data.trim())?args.data.trim():"set realmlist "+args.data.trim())+"\r\n"};rl.profiles.push(p);return p;} if(cmd==="realmlist_delete"){const rl=(window as any).__rl;rl.profiles=rl.profiles.filter((x: any)=>x.id!==args.profileId);return null;} if(cmd==="account_delete") return null; - if(cmd==="modules_list") return [{id:"companions",name:"CoA Companions",description:{en:"Companion bots for groups, dungeons and your guild.",ru:"Боты-компаньоны для групп, подземелий и гильдии."},repo:"https://github.com/Corfirean/mod-coa-playerbots",installed:true,enabled:modState["companions"],switchable:true,status:"beta",icon:"bot",page:"bots"},{id:"content-scaling",name:"Content Scaling",description:{en:"Scales dungeons and raids to your group, with solo modes.",ru:"Подстраивает подземелья и рейды под вашу группу, есть соло-режимы."},repo:"https://github.com/Corfirean/mod-coa-content-scaling",installed:true,enabled:modState["content-scaling"],switchable:true,status:"early",icon:"scaling",page:null},{id:"tbc-content",name:"TBC Content",description:{en:"Outland dungeons and raids, scaled to your level range.",ru:"Подземелья и рейды Запределья, подогнанные под ваши уровни."},repo:"https://github.com/Corfirean/mod-coa-content-scaling/tree/main/content-packs/mod-coa-tbc-content",installed:true,enabled:modState["tbc-content"],switchable:true,status:"early",icon:"tbc",page:null},{id:"wotlk-content",name:"WotLK Content",description:{en:"Northrend dungeons and raids, scaled to your level range.",ru:"Подземелья и рейды Нордскола, подогнанные под ваши уровни."},repo:"https://github.com/Corfirean/mod-coa-content-scaling/tree/main/content-packs/mod-coa-wotlk-content",installed:true,enabled:modState["wotlk-content"],switchable:true,status:"early",icon:"wotlk",page:null},{id:"ah-bot",name:"Auction House Bot",description:{en:"Fills the auction house with realistic, adjustable listings.",ru:"Наполняет аукцион реалистичными настраиваемыми лотами."},repo:"https://github.com/NathanHandley/mod-ah-bot-plus",installed:false,enabled:false,switchable:false,status:"soon",icon:"gavel",page:null,hidden:false},{id:"npc-enchanter",name:"NPC Enchanter",description:{en:"An NPC that enchants your gear for you.",ru:"НПС, который зачаровывает ваше снаряжение."},repo:"https://github.com/azerothcore/mod-npc-enchanter",installed:true,enabled:modState["npc-enchanter"],switchable:true,status:"early",icon:"sparkles",page:null,hidden:false},{id:"playerbots",name:"SQUID's Playerbots",description:{en:"An alternative bot system with random bots and full AI.",ru:"Альтернативная система ботов со случайными ботами и полным ИИ."},repo:"https://github.com/Zyth45/mod-playerbots",installed:false,enabled:false,switchable:false,status:"soon",icon:"swords",page:null,hidden:false},{id:"client-compat",name:"Client compatibility",description:{en:"Client-server link and appearance unlocks.",ru:"Связь клиента с сервером и разблокировка внешних видов."},repo:"https://github.com/Corfirean/azerothcore-wotlk-coa",installed:true,enabled:modState["client-compat"],switchable:false,status:"release",icon:"plug",page:null,hidden:true}]; + if(cmd==="modules_list") return [{id:"companions",name:"CoA Companions",description:{en:"Companion bots for groups, dungeons and your guild.",ru:"Боты-компаньоны для групп, подземелий и гильдии."},repo:"https://github.com/Corfirean/mod-coa-playerbots",installed:true,enabled:modState["companions"],switchable:true,status:"beta",icon:"bot",page:"bots"},{id:"content-scaling",name:"Content Scaling",description:{en:"Scales dungeons and raids to your group, with solo modes.",ru:"Подстраивает подземелья и рейды под вашу группу, есть соло-режимы."},repo:"https://github.com/Corfirean/mod-coa-content-scaling",installed:true,enabled:modState["content-scaling"],switchable:true,status:"early",icon:"scaling",page:null},{id:"tbc-content",name:"TBC Content",description:{en:"Outland dungeons and raids, scaled to your level range.",ru:"Подземелья и рейды Запределья, подогнанные под ваши уровни."},repo:"https://github.com/Corfirean/mod-coa-content-scaling/tree/main/content-packs/mod-coa-tbc-content",installed:true,enabled:modState["tbc-content"],switchable:true,status:"early",icon:"tbc",page:null},{id:"wotlk-content",name:"WotLK Content",description:{en:"Northrend dungeons and raids, scaled to your level range.",ru:"Подземелья и рейды Нордскола, подогнанные под ваши уровни."},repo:"https://github.com/Corfirean/mod-coa-content-scaling/tree/main/content-packs/mod-coa-wotlk-content",installed:true,enabled:modState["wotlk-content"],switchable:true,status:"early",icon:"wotlk",page:null},{id:"ah-bot",name:"Auction House Bot",description:{en:"Fills the auction house with realistic, adjustable listings.",ru:"Наполняет аукцион реалистичными настраиваемыми лотами."},repo:"https://github.com/NathanHandley/mod-ah-bot-plus",installed:false,enabled:false,switchable:false,status:"soon",icon:"gavel",page:null,hidden:false},{id:"npc-enchanter",name:"NPC Enchanter",description:{en:"An NPC that enchants your gear for you.",ru:"НПС, который зачаровывает ваше снаряжение."},repo:"https://github.com/azerothcore/mod-npc-enchanter",installed:true,enabled:modState["npc-enchanter"],switchable:true,status:"early",icon:"sparkles",page:null,hidden:false},{id:"playerbots",name:"SQUID's Playerbots",description:{en:"An alternative bot system with random bots and full AI.",ru:"Альтернативная система ботов со случайными ботами и полным ИИ."},repo:"https://github.com/Zyth45/mod-playerbots",installed:false,enabled:false,switchable:false,status:"soon",icon:"swords",page:null,hidden:false},{id:"client-compat",name:"Client compatibility",description:{en:"Client-server link and appearance unlocks.",ru:"Связь клиента с сервером и разблокировка внешних видов."},repo:"https://github.com/Corfirean/azerothcore-wotlk-coa",installed:true,enabled:modState["client-compat"],switchable:false,status:"release",icon:"plug",page:null,hidden:true}].map(m => ({...m, compatibility: realm === "wildcard" ? (["companions", "playerbots"].includes(m.id) ? "unsupported" : m.id === "client-compat" ? "compatible" : "experimental") : "compatible", switchable: realm === "wildcard" && ["companions", "playerbots"].includes(m.id) ? false : m.switchable})); if(cmd==="module_set_enabled"){ modState[args.module]=args.enabled; return null; } if(cmd==="all_settings") return [{key:"Visibility.Distance.Continents",value:"100",default:"90",doc:"Description: Visibility distance on continents. Range: 45-250 Default: 90",changed:true},{key:"Death.SicknessLevel",value:"11",default:"11",doc:"Description: Character level up to which characters get resurrection sickness. Default: 11",changed:false},{key:"Rate.Drop.Item.Epic",value:"3",default:"1",doc:"Description: Drop rates for money and items based on quality. Default: 1",changed:true},{key:"Instance.ResetTimeHour",value:"4",default:"4",doc:"Description: Hour of the day when the global instance reset occurs. Range: 0-23 Default: 4 - (04:00 AM)",changed:false},{key:"PlayerLimit",value:"1000",default:"1000",doc:"Description: Maximum number of players in the world.",changed:false}]; if(cmd==="all_settings_save") return Object.keys(args&&args.changes||{}); diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 5463747..3470f3b 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -697,6 +697,18 @@ const de: Partial> = { "rep.copied": "In die Zwischenablage kopiert.", // Realmlist switcher and modules page + "realm.profileLabel": "Realm", + "realm.switching": "Realm wird gewechselt…", + "realm.create": "Wildcard-Realm erstellen", + "realm.restart": "Wechseln und neu starten", + "realm.select": "Realm auswählen", + "realm.active": "Aktiv: {name}", + "realm.separate": "Es läuft jeweils eine Welt. Jeder Realm behält eigene Charaktere, Fortschritte und Moduleinstellungen.", + "realm.modules": "CoA-Begleiter sind hier nicht verfügbar. Content Scaling und andere Module sind auf Wildcard experimentell.", + "realm.update": "Aktualisiere den Server auf eine Version mit Wildcard-Unterstützung, um diesen Realm zu erstellen.", + "realm.recovery": "Ein Realm-Wechsel wurde unterbrochen. Wähle einen Realm, um die gespeicherte Konfiguration wiederherzustellen.", + "realm.unsupported": "Auf Wildcard nicht verfügbar: Die Klassenmechanik wurde noch nicht geprüft.", + "realm.experimental": "Experimentell auf Wildcard · Spielkompatibilität noch nicht geprüft.", "realm.label": "Realm", "realm.other": "Anderer", "realm.add": "Realmlist hinzufügen …", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 04fd349..52525dc 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1,5 +1,17 @@ /** English is the source of truth: every key must exist here; other locales fall back to it. */ const en = { + "realm.profileLabel": "Realm", + "realm.switching": "Switching realm…", + "realm.create": "Create Wildcard realm", + "realm.restart": "Switch and restart", + "realm.select": "Select realm", + "realm.active": "Active: {name}", + "realm.separate": "One world runs at a time. Each realm keeps its own characters, progress and module settings.", + "realm.modules": "CoA companions are unavailable here. Content Scaling and other modules remain experimental on Wildcard.", + "realm.update": "Update the server to a build with Wildcard support to create this realm.", + "realm.recovery": "A realm switch was interrupted. Select a realm to recover the saved configuration.", + "realm.unsupported": "Unavailable on Wildcard: class mechanics have not been validated.", + "realm.experimental": "Experimental on Wildcard · gameplay compatibility is not yet verified.", "app.name": "CoA Server Manager", "nav.overview": "Overview", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 7cb5269..8cd44d3 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -697,6 +697,18 @@ const es: Partial> = { "rep.copied": "Copiado al portapapeles.", // Realmlist switcher and modules page + "realm.profileLabel": "Reino", + "realm.switching": "Cambiando de reino…", + "realm.create": "Crear reino Wildcard", + "realm.restart": "Cambiar y reiniciar", + "realm.select": "Seleccionar reino", + "realm.active": "Activo: {name}", + "realm.separate": "Solo funciona un mundo a la vez. Cada reino conserva sus personajes, progreso y ajustes de módulos.", + "realm.modules": "Los compañeros de CoA no están disponibles aquí. Content Scaling y los demás módulos siguen siendo experimentales en Wildcard.", + "realm.update": "Actualiza el servidor a una versión compatible con Wildcard para crear este reino.", + "realm.recovery": "Se interrumpió un cambio de reino. Selecciona un reino para recuperar la configuración guardada.", + "realm.unsupported": "No disponible en Wildcard: las mecánicas de clase aún no se han validado.", + "realm.experimental": "Experimental en Wildcard · compatibilidad en juego sin verificar.", "realm.label": "Reino", "realm.other": "Otro", "realm.add": "Añadir una realmlist…", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index f79e5db..8ebab1b 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -697,6 +697,18 @@ const fr: Partial> = { "rep.copied": "Copié dans le presse-papiers.", // Realmlist switcher and modules page + "realm.profileLabel": "Royaume", + "realm.switching": "Changement de royaume…", + "realm.create": "Créer le royaume Wildcard", + "realm.restart": "Changer et redémarrer", + "realm.select": "Choisir le royaume", + "realm.active": "Actif : {name}", + "realm.separate": "Un seul monde fonctionne à la fois. Chaque royaume conserve ses personnages, sa progression et les paramètres de ses modules.", + "realm.modules": "Les compagnons CoA sont indisponibles ici. Content Scaling et les autres modules restent expérimentaux sur Wildcard.", + "realm.update": "Mettez le serveur à jour vers une version compatible avec Wildcard pour créer ce royaume.", + "realm.recovery": "Un changement de royaume a été interrompu. Choisissez un royaume pour restaurer la configuration enregistrée.", + "realm.unsupported": "Indisponible sur Wildcard : les mécaniques de classe ne sont pas encore validées.", + "realm.experimental": "Expérimental sur Wildcard · compatibilité en jeu non vérifiée.", "realm.label": "Royaume", "realm.other": "Autre", "realm.add": "Ajouter une realmlist…", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index d61937c..f93dda0 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -2,6 +2,18 @@ import type en from "./en"; /** Русский. Черновой перевод; требует проверки носителем перед релизом. */ const ru: Partial> = { + "realm.profileLabel": "Реалм", + "realm.switching": "Переключение реалма…", + "realm.create": "Создать реалм Wildcard", + "realm.restart": "Переключить и перезапустить", + "realm.select": "Выбрать реалм", + "realm.active": "Активен: {name}", + "realm.separate": "Работает один мир. У каждого реалма свои персонажи, прогресс и настройки модулей.", + "realm.modules": "CoA-боты здесь недоступны. Скалирование и другие модули пока экспериментальны для Wildcard.", + "realm.update": "Для создания реалма обновите сервер до сборки с поддержкой Wildcard.", + "realm.recovery": "Переключение прервалось. Выберите реалм, чтобы восстановить сохранённые настройки.", + "realm.unsupported": "Недоступен в Wildcard: классовые механики ещё не проверены.", + "realm.experimental": "Экспериментально в Wildcard · игровая совместимость пока не проверена.", "nav.overview": "Обзор", "nav.bots": "Боты", "nav.server": "Сервер", diff --git a/src/lib/api.ts b/src/lib/api.ts index d66a96f..0b5c95e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -50,6 +50,9 @@ export interface StatusView { path_exists: boolean; } +export type RealmMode = "coa" | "wildcard"; +export interface RealmProfiles { active: RealmMode; wildcard_created: boolean; supported: boolean; recovery_pending: boolean } + export interface Human { code: string; title: string; @@ -149,6 +152,7 @@ export interface SaveReport { export type BackupKind = "quick" | "full" | "config" | "database"; export interface RecoveryPoint { + realm?: RealmMode; schema: number; id: string; kind: BackupKind; @@ -193,6 +197,7 @@ export interface ModuleView { page: string | null; /** not shown on the Modules page */ hidden: boolean; + compatibility?: "compatible" | "experimental" | "unsupported"; } export interface ModuleSetting { @@ -361,6 +366,8 @@ export interface ConsoleLine { } export const api = { + realmProfiles: (id: string) => invoke("realm_profiles", { id }), + realmSelect: (id: string, mode: RealmMode, restart: boolean) => invoke("realm_select", { id, mode, restart }), defaultInstallDir: () => invoke("default_install_dir"), scan: (path: string) => invoke("scan_server", { path }), add: (path: string) => invoke("add_server", { path }), diff --git a/src/screens/BackupsPage.tsx b/src/screens/BackupsPage.tsx index 288ac9e..84144f5 100644 --- a/src/screens/BackupsPage.tsx +++ b/src/screens/BackupsPage.tsx @@ -33,7 +33,11 @@ export function BackupsPage({ serverId }: { serverId: string }) { const human = useHuman(); const w = (iso: string) => when(iso, t, locale); const trigger = (x: string) => (TRIGGER_KEYS.includes(x) ? t(`bk.trigger.${x}` as Key) : x); - const dbLabel = (x: string) => (DB_KEYS.includes(x) ? t(`bk.db.${x}` as Key) : x); + const dbLabel = (x: string) => { + const [realm, kind] = x.split("-"); + if (kind && DB_KEYS.includes(kind)) return `${realm === "coa" ? "CoA" : "Wildcard"} · ${t(`bk.db.${kind}` as Key)}`; + return DB_KEYS.includes(x) ? t(`bk.db.${x}` as Key) : x; + }; const [points, setPoints] = useState(null); const [busy, setBusy] = useState(null); const [step, setStep] = useState(null); @@ -174,7 +178,7 @@ export function BackupsPage({ serverId }: { serverId: string }) {

{p.label && p.trigger === "manual" && p.label !== "e2e" ? p.label : trigger(p.trigger)}

- {w(p.created_at)} · {t(`bk.kind.${p.kind}` as Key)} · {size(p.components.reduce((a, c) => a + c.bytes, 0))} + {w(p.created_at)} · {p.realm === "wildcard" ? "Wildcard" : "CoA"} · {t(`bk.kind.${p.kind}` as Key)} · {size(p.components.reduce((a, c) => a + c.bytes, 0))}

diff --git a/src/screens/ModulesPage.tsx b/src/screens/ModulesPage.tsx index e3907c9..7309123 100644 --- a/src/screens/ModulesPage.tsx +++ b/src/screens/ModulesPage.tsx @@ -134,6 +134,8 @@ export function ModulesPage({ serverId, onChanged }: { serverId: string; onChang

{m.description[locale] ?? m.description.en}

+ {m.compatibility === "unsupported" &&

{t("realm.unsupported")}

} + {m.compatibility === "experimental" &&

{t("realm.experimental")}

} {m.installed && m.switchable && ( )} - {m.installed && !m.switchable && m.status !== "soon" && {t("mod.alwaysOn")}} + {m.installed && !m.switchable && m.status !== "soon" && m.compatibility !== "unsupported" && {t("mod.alwaysOn")}} {!m.installed && m.status !== "soon" && {t("mod.notHere")}}
@@ -158,7 +160,7 @@ export function ModulesPage({ serverId, onChanged }: { serverId: string; onChang > - {m.installed && m.status !== "soon" && ( + {m.installed && m.status !== "soon" && m.compatibility !== "unsupported" && ( diff --git a/src/screens/Overview.tsx b/src/screens/Overview.tsx index cab9a83..008e563 100644 --- a/src/screens/Overview.tsx +++ b/src/screens/Overview.tsx @@ -8,6 +8,7 @@ import { applyServerUpdate, useServerUpdate } from "@/lib/serverUpdate"; import { checkClient, useClientStatus } from "@/lib/clientUpdate"; import { ClientDialog, type ClientDialogMode } from "@/screens/ClientDialog"; import { RealmlistMenu } from "@/screens/RealmlistMenu"; +import { RealmPicker } from "@/screens/RealmPicker"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -63,11 +64,12 @@ function Row({ label, s }: { label: string; s: ServiceStatus }) { ); } -export function Overview({ server, companions = true, onForget, onOpenUpdates }: { server: ServerSummary; /** the companions module is on */ companions?: boolean; onForget: () => Promise; onOpenUpdates: () => void }) { +export function Overview({ server, companions = true, onForget, onOpenUpdates, onRealmChanged }: { server: ServerSummary; /** the companions module is on */ companions?: boolean; onForget: () => Promise; onOpenUpdates: () => void; onRealmChanged: () => void }) { const t = useT(); const human = useHuman(); const [status, setStatus] = useState(null); const [action, setAction] = useState(null); + const [realmBusy, setRealmBusy] = useState(false); const [failure, setFailure] = useState(null); const [showDetails, setShowDetails] = useState(false); const [pop, setPop] = useState(null); @@ -197,10 +199,10 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates }: const all = [mysql, auth, world]; const running = all.every((s) => s.state === "running"); const anyUp = all.some((s) => s.state !== "stopped"); - const transitioning = action !== null || status.busy; + const transitioning = action !== null || status.busy || realmBusy; const conflict = all.find((s) => s.conflict)?.conflict ?? null; - const headline = transitioning + const headline = realmBusy ? t("realm.switching") : transitioning ? action === "stopping" ? t("status.stopping") : t("status.starting") @@ -215,6 +217,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates }:

{server.name}

{server.path}

+ { onRealmChanged(); void poll(); }} />
{t("overview.server")}
diff --git a/src/screens/RealmPicker.tsx b/src/screens/RealmPicker.tsx new file mode 100644 index 0000000..6751db8 --- /dev/null +++ b/src/screens/RealmPicker.tsx @@ -0,0 +1,53 @@ +import { useEffect, useState } from "react"; +import { Layers, Loader2 } from "lucide-react"; +import { api, asUiError, type RealmMode, type RealmProfiles } from "@/lib/api"; +import { useT } from "@/i18n"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; + +export function RealmPicker({ serverId, running, disabled, onChanged, onBusy }: { + serverId: string; running: boolean; disabled: boolean; onChanged: () => void; onBusy: (busy: boolean) => void; +}) { + const t = useT(); + const [view, setView] = useState(null); + const [selected, setSelected] = useState("coa"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + useEffect(() => { + let alive = true; + void api.realmProfiles(serverId).then(v => { if (alive) { setView(v); setSelected(v.active); } }).catch(e => { if (alive) setError(asUiError(e).technical); }); + return () => { alive = false; }; + }, [serverId]); + + async function select() { + setBusy(true); onBusy(true); setError(null); + try { const next = await api.realmSelect(serverId, selected, running); setView(next); onChanged(); } + catch (e) { + setError(asUiError(e).technical); + // Selection may have committed even if the subsequent server restart failed. + try { setView(await api.realmProfiles(serverId)); onChanged(); } catch { /* keep the operation error */ } + } + finally { setBusy(false); onBusy(false); } + } + + return +
+ + + + {view && (selected !== view.active || view.recovery_pending) && } + {view && {t("realm.active", { name: view.active === "coa" ? "CoA" : "Wildcard" })}} +
+

{t("realm.separate")}

+ {selected === "wildcard" &&

{t("realm.modules")}

} + {view && !view.supported &&

{t("realm.update")}

} + {view?.recovery_pending &&

{t("realm.recovery")}

} + {error &&

{error}

} +
; +} diff --git a/src/screens/Shell.tsx b/src/screens/Shell.tsx index b9cd991..8dfe98c 100644 --- a/src/screens/Shell.tsx +++ b/src/screens/Shell.tsx @@ -51,7 +51,7 @@ export function Shell(props: { const reloadModules = useCallback(() => { void api .modulesList(props.activeId) - .then((list) => setHiddenPages(list.filter((m) => m.page && (!m.installed || (m.switchable && !m.enabled))).map((m) => m.page as string))) + .then((list) => setHiddenPages(list.filter((m) => m.page && (!m.installed || m.compatibility === "unsupported" || (m.switchable && !m.enabled))).map((m) => m.page as string))) .catch(() => setHiddenPages([])); }, [props.activeId]); useEffect(() => { @@ -122,7 +122,7 @@ export function Shell(props: {
{page === "overview" ? ( - props.onForget(server.id)} onOpenUpdates={() => setPage("settings")} /> + props.onForget(server.id)} onOpenUpdates={() => setPage("settings")} onRealmChanged={reloadModules} /> ) : page === "bots" || page === "server" ? ( ) : page === "console" ? ( diff --git a/tools/make-fixture.ps1 b/tools/make-fixture.ps1 index 1b40366..1f5f64c 100644 --- a/tools/make-fixture.ps1 +++ b/tools/make-fixture.ps1 @@ -3,7 +3,8 @@ param( [string]$Source = 'C:\games\CoA-Repack', [string]$Target = 'C:\games\coa-fixture', - [int]$PortBase = 13000 + [int]$PortBase = 13000, + [string]$DatabaseArchive = '' ) $ErrorActionPreference = 'Stop' if ((Resolve-Path $Source).Path -eq $Target) { throw 'source and target are the same' } @@ -27,7 +28,8 @@ foreach ($d in 'bin', 'lib', 'share') { Copy-Tree "mysql\$d" } Copy-Item "$Source\RELEASE.json", "$Source\MANIFEST.json" $Target # Pristine packaged database (never the live data directory). -& 'C:\Program Files\7-Zip\7z.exe' x "$Source\mysql\data.7z" "-o$Target\mysql" -y | Out-Null +if (-not $DatabaseArchive) { $DatabaseArchive = "$Source\mysql\data.7z" } +& 'C:\Program Files\7-Zip\7z.exe' x $DatabaseArchive "-o$Target\mysql" -y | Out-Null if (-not (Test-Path "$Target\mysql\data\acore_characters")) { throw 'database extraction failed' } New-Item -ItemType Junction -Path "$Target\Data" -Target "$Source\Data" | Out-Null diff --git a/vite.config.ts b/vite.config.ts index 2767b47..906b72b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,6 @@ export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } }, clearScreen: false, - server: { port: 1420, strictPort: true }, + server: { port: 1420, strictPort: true, watch: { ignored: ["**/target/**", "**/src-tauri/**"] } }, build: { target: "es2022", outDir: "dist" }, });