From 321ac597ce6380bc62b943a213f4b77abbbfb3c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 27 Oct 2026 13:09:14 +0500 Subject: [PATCH] feat: start Wildcard and Warcraft Reborn together with Conquest of Azeroth Settings gets two options, one per extra realm; turning on both runs all three realms. Each extra world keeps its own folder, configs, logs and ports (multiworld now handles a set of worlds instead of one second world), and Warcraft Reborn gets its own status card, realm list entry and firewall rule. Starting a server also sets CoA.ClassModel of the realm chosen on Overview to exactly that realm's model, so the shipped "coa wcr" default no longer shows two class models on a Conquest of Azeroth realm. Co-Authored-By: Claude Sonnet 5.5 --- ...27-start-checks-the-realm-class-setting.md | 7 + ...1027-start-wildcard-and-reborn-with-coa.md | 7 + crates/coa-core/src/diag.rs | 4 + crates/coa-core/src/docker/lifecycle.rs | 3 + crates/coa-core/src/driver.rs | 2 +- crates/coa-core/src/firewall.rs | 16 +- crates/coa-core/src/friends.rs | 16 +- crates/coa-core/src/multiworld.rs | 276 +++++++++++---- crates/coa-core/src/mysql_snapshot.rs | 3 +- crates/coa-core/src/process.rs | 47 ++- crates/coa-core/src/realm_registry/advert.rs | 5 +- crates/coa-core/src/realms.rs | 333 +++++++++++++++--- crates/coa-core/src/update.rs | 5 +- crates/coa-core/src/update_isolation.rs | 18 +- src-tauri/src/lib.rs | 28 +- src/dev/mock.ts | 10 +- src/i18n/locales/en.ts | 11 +- src/i18n/locales/ru.ts | 11 +- src/lib/api.ts | 6 +- src/screens/FriendsPage.tsx | 1 + src/screens/Overview.tsx | 32 +- src/screens/RealmPicker.tsx | 10 +- src/screens/RealmStartupSettings.tsx | 17 +- src/screens/RemoveServerCard.tsx | 2 +- 24 files changed, 697 insertions(+), 173 deletions(-) create mode 100644 changelog.d/20261027-start-checks-the-realm-class-setting.md create mode 100644 changelog.d/20261027-start-wildcard-and-reborn-with-coa.md diff --git a/changelog.d/20261027-start-checks-the-realm-class-setting.md b/changelog.d/20261027-start-checks-the-realm-class-setting.md new file mode 100644 index 0000000..f82889b --- /dev/null +++ b/changelog.d/20261027-start-checks-the-realm-class-setting.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Starting a server sets the character classes of the realm chosen on Overview +--- +A new Conquest of Azeroth server could show two class models, or none, on the character creation screen. The Manager now sets the model of the chosen realm before every start. diff --git a/changelog.d/20261027-start-wildcard-and-reborn-with-coa.md b/changelog.d/20261027-start-wildcard-and-reborn-with-coa.md new file mode 100644 index 0000000..177fd52 --- /dev/null +++ b/changelog.d/20261027-start-wildcard-and-reborn-with-coa.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Start Wildcard and Warcraft Reborn together with Conquest of Azeroth +--- +Settings now has two options, one for Wildcard and one for Warcraft Reborn. Turn on either one, or both to run all three realms at once, each with its own characters and port. diff --git a/crates/coa-core/src/diag.rs b/crates/coa-core/src/diag.rs index 10ca4a8..63c80ad 100644 --- a/crates/coa-core/src/diag.rs +++ b/crates/coa-core/src/diag.rs @@ -96,6 +96,9 @@ pub fn run(root: &Path, meta: &InstallMeta) -> Report { if let Some(second) = &o.secondary_world { services.push(("Second world", second)); } + if let Some(reborn) = &o.reborn_world { + services.push(("Warcraft Reborn world", reborn)); + } for (title, s) in services { let (lvl, text) = match (s.state, &s.conflict) { (ServiceState::Running, _) => (Level::Ok, "Running".to_string()), @@ -639,6 +642,7 @@ fn processes_report(root: &Path, ports: &layout::Ports) -> String { Some(&o.auth), Some(&o.world), o.secondary_world.as_ref(), + o.reborn_world.as_ref(), ] .into_iter() .flatten() diff --git a/crates/coa-core/src/docker/lifecycle.rs b/crates/coa-core/src/docker/lifecycle.rs index c249b9a..c84ace0 100644 --- a/crates/coa-core/src/docker/lifecycle.rs +++ b/crates/coa-core/src/docker/lifecycle.rs @@ -758,6 +758,7 @@ pub fn observe_with(d: &dyn Docker, root: &Path, ports: &Ports) -> Observed { auth: unknown("auth", ports.auth), world: unknown("world", ports.world), secondary_world: None, + reborn_world: None, }; }; let n = cfg.names(); @@ -767,6 +768,7 @@ pub fn observe_with(d: &dyn Docker, root: &Path, ports: &Ports) -> Observed { auth: unknown("auth", ports.auth), world: unknown("world", ports.world), secondary_world: None, + reborn_world: None, }; }; let ip = connect_ip(&cfg); @@ -795,6 +797,7 @@ pub fn observe_with(d: &dyn Docker, root: &Path, ports: &Ports) -> Observed { listening(state.get(&n.world), ports.world), ), secondary_world: None, + reborn_world: None, } } diff --git a/crates/coa-core/src/driver.rs b/crates/coa-core/src/driver.rs index 711d218..94aee93 100644 --- a/crates/coa-core/src/driver.rs +++ b/crates/coa-core/src/driver.rs @@ -254,7 +254,7 @@ fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result Result<()> { /// Make sure both rules exist (adds only the missing ones) and confirm afterwards. pub fn ensure_rules(ports: &Ports) -> Result { - ensure_rules_with_secondary(ports, None) + ensure_rules_with_extra(ports, &[]) } -pub fn ensure_rules_with_secondary(ports: &Ports, secondary: Option) -> Result { +/// As `ensure_rules`, plus one rule for the world port of each extra realm that runs next to the main one. +pub fn ensure_rules_with_extra(ports: &Ports, extra: &[u16]) -> Result { let have = status(); let mut cmds = plan(ports, &have); - let second_name = secondary.map(|port| format!("CoA Server Manager - Second World {port}")); - if let (Some(port), Some(name)) = (secondary, &second_name) { + let extra_names: Vec = extra + .iter() + .map(|port| format!("CoA Server Manager - Second World {port}")) + .collect(); + for (port, name) in extra.iter().zip(&extra_names) { if !rule_exists(name) { - cmds.push(add_cmd(name, port)); + cmds.push(add_cmd(name, *port)); } } if !cmds.is_empty() { run_elevated(&cmds)?; } let now = status(); - if now.auth && now.world && second_name.as_ref().is_none_or(|name| rule_exists(name)) { + if now.auth && now.world && extra_names.iter().all(|name| rule_exists(name)) { Ok(now) } else { Err(Error::Invalid( diff --git a/crates/coa-core/src/friends.rs b/crates/coa-core/src/friends.rs index 89999b1..4b9500e 100644 --- a/crates/coa-core/src/friends.rs +++ b/crates/coa-core/src/friends.rs @@ -210,12 +210,16 @@ pub fn apply_realm_address(root: &Path, settings: &Settings) -> Result<()> { Mode::Private => None, }; let db = Db::from_repack(root, Account::Admin)?; - let realm = crate::realms::state(root)?.active.realm_id(); - let where_realms = if crate::realms::state(root)?.simultaneous { - "id IN (1,2)".into() - } else { - format!("id={realm}") - }; + let state = crate::realms::state(root)?; + let mut ids = vec![state.active]; + ids.extend(state.extra_modes()); + let where_realms = format!( + "id IN ({})", + ids.iter() + .map(|m| m.realm_id().to_string()) + .collect::>() + .join(",") + ); db.query(&realm_address_sql(host, local, &where_realms)?)?; Ok(()) } diff --git a/crates/coa-core/src/multiworld.rs b/crates/coa-core/src/multiworld.rs index 5ec782d..d5327e4 100644 --- a/crates/coa-core/src/multiworld.rs +++ b/crates/coa-core/src/multiworld.rs @@ -12,12 +12,66 @@ use std::{ path::{Path, PathBuf}, }; -pub fn root(main: &Path) -> PathBuf { - main.join(".realms/secondary") +/// 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, } -pub fn is_running(main: &Path) -> bool { - let second = root(main); +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; } @@ -28,6 +82,11 @@ pub fn is_running(main: &Path) -> bool { .any(|n| process::read_state_record(&second, n).is_some_and(|p| process::is_alive(&p))) } +/// 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)? { @@ -55,23 +114,52 @@ fn copy_tree(from: &Path, to: &Path) -> Result<()> { 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 { - if realms::state(main)?.active == Mode::Reborn { - return Err(Error::Invalid( - "Simultaneous startup supports CoA and Wildcard. Select one of these realms first." - .into(), - )); - } - if !realms::view(main)?.supported { - return Err(Error::Invalid( - "Update the server to a build with Wildcard support first.".into(), - )); + 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"))?)?; } @@ -81,20 +169,21 @@ pub fn set_enabled(main: &Path, enabled: bool) -> Result { || is_running(main) { return Err(Error::Invalid( - "Stop both realms before changing simultaneous startup.".into(), + "Stop all realms before changing which realms start together.".into(), )); } - let original = realms::state(main)?.active; - if enabled && !realms::state(main)?.wildcard_created { - realms::select(main, Mode::Wildcard)?; - if original != Mode::Wildcard { - realms::select(main, original)?; - } + 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); @@ -104,31 +193,41 @@ pub fn set_enabled(main: &Path, enabled: bool) -> Result { .find(|p| { !reserved.contains(p) && std::net::TcpListener::bind(("127.0.0.1", *p)).is_ok() }) - .ok_or_else(|| Error::Invalid("No free port for the second realm.".into()))?; + .ok_or_else(|| Error::Invalid(format!("No free port for the {}.", tree.label())))?; reserved.push(port); Ok(port) }; - s.secondary_world_port = Some(choose(s.secondary_world_port, p.world)?); - s.secondary_ra_port = Some(choose(s.secondary_ra_port, p.ra)?); + 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, } - s.simultaneous = enabled; fsx::atomic_write_json(&main.join("Settings/realm-profile.json"), &s)?; realms::view(main) } -fn prepare(main: &Path) -> Result { - if is_running(main) { - return Err(Error::Invalid( - "The second realm is still running; stop it before preparing its files.".into(), - )); +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 = if s.active == Mode::Coa { - Mode::Wildcard - } else { - Mode::Coa - }; - let second = fsx::ensure_within(main, &root(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), @@ -214,13 +313,12 @@ fn prepare(main: &Path) -> Result { )?; } let mut cfg: serde_json::Value = fsx::read_json(&main.join("Settings/repack.json"))?; - cfg["worldPort"] = s - .secondary_world_port - .ok_or_else(|| Error::Invalid("Second realm world port is missing.".into()))? + 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"] = s - .secondary_ra_port - .ok_or_else(|| Error::Invalid("Second realm console port is missing.".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( @@ -232,6 +330,7 @@ fn prepare(main: &Path) -> Result { &RealmState { active: mode, wildcard_created: true, + reborn_created: true, ..Default::default() }, )?; @@ -256,12 +355,9 @@ fn secondary_launcher(source: &str) -> Result { )) } -pub fn start(main: &Path) -> Result<()> { - if !realms::state(main)?.simultaneous { - return Ok(()); - } - if is_running(main) { - let second = root(main); +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 @@ -269,35 +365,50 @@ pub fn start(main: &Path) -> Result<()> { { return Ok(()); } - return Err(Error::Invalid( - "The second realm is still starting or stopping.".into(), - )); + return Err(Error::Invalid(format!( + "The {} is still starting or stopping.", + tree.label() + ))); } - let second = prepare(main)?; + let second = prepare(main, tree)?; let out = driver::run(&second, Verb::StartWorld)?; if !out.ok { return Err(Error::Invalid(format!( - "The second realm did not start: {}", + "The {} did not start: {}", + tree.label(), out.output ))); } Ok(()) } -pub fn stop(main: &Path) -> Result<()> { - let second = root(main); +/// 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 is_running(main) { - return Err(Error::Invalid("The running second realm's launcher is missing. The shared database was left running.".into())); + 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 is_running(main) { + if tree_running(main, tree) { let out = driver::run(&second, Verb::StopAll)?; - if !out.ok || is_running(main) { + if !out.ok || tree_running(main, tree) { return Err(Error::Invalid(format!( - "The second realm could not stop; the database was left running: {}", + "The {} could not stop; the database was left running: {}", + tree.label(), out.output ))); } @@ -305,9 +416,54 @@ pub fn stop(main: &Path) -> Result<()> { 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 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(); + assert!(err.contains("Select Conquest of Azeroth first"), "{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(); diff --git a/crates/coa-core/src/mysql_snapshot.rs b/crates/coa-core/src/mysql_snapshot.rs index fc8ff56..42b2988 100644 --- a/crates/coa-core/src/mysql_snapshot.rs +++ b/crates/coa-core/src/mysql_snapshot.rs @@ -284,7 +284,7 @@ fn stop_failure( )]; for service in [&status.mysql, &status.auth, &status.world] .into_iter() - .chain(status.secondary_world.iter()) + .chain(status.extra_worlds()) { if service.state != crate::process::ServiceState::Stopped { details.push(format!( @@ -506,6 +506,7 @@ mod tests { auth: stopped("auth"), world: stopped("world"), secondary_world: None, + reborn_world: None, }; let outcome = crate::driver::DriverOutcome { ok: false, diff --git a/crates/coa-core/src/process.rs b/crates/coa-core/src/process.rs index bdac5b8..2d2c897 100644 --- a/crates/coa-core/src/process.rs +++ b/crates/coa-core/src/process.rs @@ -64,6 +64,16 @@ pub struct Observed { pub auth: ServiceStatus, pub world: ServiceStatus, pub secondary_world: Option, + /// The Warcraft Reborn world that starts together with Conquest of Azeroth. + #[serde(default)] + pub reborn_world: Option, +} + +impl Observed { + /// The worlds that run next to the primary one. + pub fn extra_worlds(&self) -> impl Iterator { + self.secondary_world.iter().chain(self.reborn_world.iter()) + } } pub fn read_state_record(root: &Path, name: &str) -> Option { @@ -392,20 +402,30 @@ pub fn observe(root: &Path, ports: &Ports) -> Observed { } let listen = listeners(); let (world, auth, mysql) = crate::layout::executables(root); - let secondary = crate::multiworld::root(root); - let secondary_world = if crate::realms::state(root).is_ok_and(|s| s.simultaneous) - || crate::multiworld::is_running(root) - { - Some(service( - &secondary, - "world-secondary", - secondary.join("Core/worldserver.exe"), - crate::layout::read_ports(&secondary).world, - &listen, - )) - } else { - None + use crate::multiworld::Tree; + let realm_state = crate::realms::state(root).ok(); + let extra_world = |tree: Tree, name: &'static str, enabled: bool| { + let folder = crate::multiworld::tree_root(root, tree); + (enabled || crate::multiworld::tree_running(root, tree)).then(|| { + service( + &folder, + name, + folder.join("Core/worldserver.exe"), + crate::layout::read_ports(&folder).world, + &listen, + ) + }) }; + let secondary_world = extra_world( + Tree::Second, + "world-secondary", + realm_state.as_ref().is_some_and(|s| s.simultaneous), + ); + let reborn_world = extra_world( + Tree::Reborn, + "world-reborn", + realm_state.as_ref().is_some_and(|s| s.with_reborn), + ); Observed { mysql: service(root, "mysql", mysql, ports.mysql, &listen), auth: service(root, "auth", auth, ports.auth, &listen), @@ -418,6 +438,7 @@ pub fn observe(root: &Path, ports: &Ports) -> Observed { &listen, ), secondary_world, + reborn_world, } } diff --git a/crates/coa-core/src/realm_registry/advert.rs b/crates/coa-core/src/realm_registry/advert.rs index 07e6c64..d9d66db 100644 --- a/crates/coa-core/src/realm_registry/advert.rs +++ b/crates/coa-core/src/realm_registry/advert.rs @@ -154,7 +154,10 @@ pub fn find_access( st.active == crate::realms::Mode::Wildcard || (st.simultaneous && st.wildcard_created) } - crate::realms::Mode::Reborn => st.active == crate::realms::Mode::Reborn, + crate::realms::Mode::Reborn => { + st.active == crate::realms::Mode::Reborn + || (st.with_reborn && st.reborn_created) + } }; if !supported { return None; diff --git a/crates/coa-core/src/realms.rs b/crates/coa-core/src/realms.rs index 0683360..d0bed69 100644 --- a/crates/coa-core/src/realms.rs +++ b/crates/coa-core/src/realms.rs @@ -63,15 +63,39 @@ pub struct RealmState { pub wildcard_created: bool, #[serde(default)] pub reborn_created: bool, + /// Wildcard starts together with Conquest of Azeroth (the second of the two when Wildcard is the primary realm). #[serde(default)] pub simultaneous: bool, #[serde(default)] pub secondary_world_port: Option, #[serde(default)] pub secondary_ra_port: Option, + /// Warcraft Reborn starts together with Conquest of Azeroth, which is then the primary realm. + #[serde(default)] + pub with_reborn: bool, + #[serde(default)] + pub reborn_world_port: Option, + #[serde(default)] + pub reborn_ra_port: Option, } impl RealmState { + /// Realms that run as a world of their own next to the primary one. + pub fn extra_modes(&self) -> Vec { + let mut modes = Vec::new(); + if self.simultaneous { + modes.push(if self.active == Mode::Coa { + Mode::Wildcard + } else { + Mode::Coa + }); + } + if self.with_reborn { + modes.push(Mode::Reborn); + } + modes + } + pub fn created(&self, mode: Mode) -> bool { match mode { Mode::Coa => true, @@ -101,6 +125,9 @@ pub struct View { pub simultaneous: bool, pub secondary_world_port: Option, pub secondary_running: bool, + pub with_reborn: bool, + pub reborn_world_port: Option, + pub reborn_running: bool, } pub fn state(root: &Path) -> Result { @@ -139,7 +166,10 @@ pub fn view(root: &Path) -> Result { recovery_pending: root.join(JOURNAL).exists(), simultaneous: s.simultaneous, secondary_world_port: s.secondary_world_port, - secondary_running: crate::multiworld::is_running(root), + secondary_running: crate::multiworld::tree_running(root, crate::multiworld::Tree::Second), + with_reborn: s.with_reborn, + reborn_world_port: s.reborn_world_port, + reborn_running: crate::multiworld::tree_running(root, crate::multiworld::Tree::Reborn), }) } @@ -447,6 +477,58 @@ fn create_databases(root: &Path, mode: Mode) -> Result<()> { }) } +/// Create the saved settings and the databases of a realm that was never set up. `before` is the current +/// configuration, the base of a new realm while Conquest of Azeroth is the active one. +fn create_realm(root: &Path, s: &mut RealmState, mode: Mode, before: &Files) -> Result<()> { + if s.created(mode) { + return Ok(()); + } + let base = if s.active == Mode::Coa { + before.clone() + } else { + fsx::read_json(&snapshot(root, Mode::Coa))? + }; + let files = match mode { + Mode::Wildcard => wildcard_configs(base)?, + Mode::Reborn => reborn_configs(base)?, + Mode::Coa => unreachable!(), + }; + fsx::atomic_write_json(&snapshot(root, mode), &files)?; + create_databases(root, mode)?; + match mode { + Mode::Wildcard => s.wildcard_created = true, + Mode::Reborn => s.reborn_created = true, + Mode::Coa => {} + } + fsx::atomic_write_json(&root.join(STATE), s)?; + Ok(()) +} + +/// Set a realm up without making it the active one, so it can start next to the active realm. +pub fn ensure_created(root: &Path, mode: Mode) -> Result<()> { + let meta = crate::registry::metadata_dir_for(root)?; + let _update_lock = crate::update::operation_lock(&meta)?; + crate::update::ensure_recovered(&meta)?; + require_world_stopped(root)?; + recover(root)?; + let mut s = state(root)?; + let supported = view(root)?; + if mode == Mode::Wildcard && !supported.supported { + return Err(Error::Invalid( + "Update the server to a build with Wildcard support first.".into(), + )); + } + if mode == Mode::Reborn && !supported.reborn_supported { + return Err(Error::Invalid( + "Update the server to a build with Warcraft Reborn support first.".into(), + )); + } + // Verify launcher support before creating databases, as selecting a realm does. + prepare_launcher(root)?; + let before = configs(root)?; + create_realm(root, &mut s, mode, &before) +} + pub fn select(root: &Path, mode: Mode) -> Result { let meta = crate::registry::metadata_dir_for(root)?; let _update_lock = crate::update::operation_lock(&meta)?; @@ -467,35 +549,16 @@ pub fn select(root: &Path, mode: Mode) -> Result { "Update the server to a build with Warcraft Reborn support first.".into(), )); } - if s.simultaneous && mode != s.active { + if (s.simultaneous || s.with_reborn) && mode != s.active { return Err(Error::Invalid( - "Disable simultaneous startup before switching realms.".into(), + "Disable the realms that start together before switching realms.".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 !s.created(mode) { - let base = if s.active == Mode::Coa { - before.clone() - } else { - fsx::read_json(&snapshot(root, Mode::Coa))? - }; - let files = match mode { - Mode::Wildcard => wildcard_configs(base)?, - Mode::Reborn => reborn_configs(base)?, - Mode::Coa => unreachable!(), - }; - fsx::atomic_write_json(&snapshot(root, mode), &files)?; - create_databases(root, mode)?; - match mode { - Mode::Wildcard => s.wildcard_created = true, - Mode::Reborn => s.reborn_created = true, - Mode::Coa => {} - } - fsx::atomic_write_json(&root.join(STATE), &s)?; - } + create_realm(root, &mut s, mode, &before)?; 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"))?; @@ -653,9 +716,46 @@ pub(crate) fn unpatch_launcher(source: &str) -> Option { (check == source).then_some(original) } +/// The character-creation model the world server advertises for a realm. Every realm has its own database and its own +/// entry on Overview, so it advertises exactly one model: a list such as `"coa wcr"` would let players create +/// Warcraft Reborn characters in the Conquest of Azeroth realm. +fn class_model_for(mode: Mode) -> &'static str { + match mode { + Mode::Coa => "coa", + Mode::Wildcard => "hero", + Mode::Reborn => "wcr", + } +} + +/// The client builds its character-creation screen from `CoA.ClassModel`, so a realm whose setting lacks its own +/// model would show the wrong screen (a Conquest of Azeroth realm set to `"hero"` has no classes). The realm chosen in +/// the Manager wins: any other setting is replaced by the realm's own model. +/// A missing key means the server default, `"coa"`; a missing file is left to the server. +fn align_class_model(root: &Path, mode: Mode) -> Result<()> { + let path = root.join("Core/configs/modules/coa.conf"); + let Ok(bytes) = fs::read(&path) else { + return Ok(()); + }; + let mut conf = ConfFile::parse_bytes(&bytes)?; + let current = conf + .get("CoA.ClassModel") + .map(crate::config::parser::unquote) + .unwrap_or("coa") + .to_string(); + let wanted = class_model_for(mode); + if current.split_whitespace().eq([wanted]) { + return Ok(()); + } + conf.set("CoA.ClassModel", &format!("\"{wanted}\""), &[]); + fsx::atomic_write(&path, conf.to_text().as_bytes())?; + tracing::warn!(realm = mode.name(), from = %current, to = wanted, "CoA.ClassModel did not fit the selected realm and was corrected"); + Ok(()) +} + pub fn before_start(root: &Path) -> Result<()> { recover(root)?; let s = state(root)?; + align_class_model(root, s.active)?; if !root.join(STATE).exists() { return Ok(()); } @@ -665,12 +765,22 @@ pub fn before_start(root: &Path) -> Result<()> { "The installed server build does not support simultaneous Wildcard startup.".into(), )); } - if s.simultaneous && s.active == Mode::Reborn { + if (s.simultaneous || s.with_reborn) && s.active == Mode::Reborn { return Err(Error::Invalid( - "Simultaneous startup supports CoA and Wildcard only.".into(), + "Realms that start together need Conquest of Azeroth or Wildcard as the main realm.".into(), )); } - if s.simultaneous { + if s.with_reborn && s.active != Mode::Coa { + return Err(Error::Invalid( + "Warcraft Reborn can start together with Conquest of Azeroth only. Select Conquest of Azeroth first.".into(), + )); + } + if s.with_reborn && !view(root)?.reborn_supported { + return Err(Error::Invalid( + "The installed server build does not support Warcraft Reborn.".into(), + )); + } + if s.simultaneous || s.with_reborn { fsx::atomic_write_json(&snapshot(root, s.active), &configs(root)?)?; } if s.active == Mode::Reborn && !view(root)?.reborn_supported { @@ -734,37 +844,38 @@ pub fn setup_realmlist(root: &Path) -> Result<()> { }; db.query(&format!("UPDATE acore_auth.realmlist SET port=CASE id WHEN 1 THEN {coa} ELSE {wildcard} END,flag=0 WHERE id IN (1,2);"))?; } + if s.with_reborn { + let reborn = s + .reborn_world_port + .ok_or_else(|| Error::Invalid("Warcraft Reborn realm port is missing.".into()))?; + db.query(&format!( + "UPDATE acore_auth.realmlist SET port={reborn},flag=0 WHERE id={};", + Mode::Reborn.realm_id() + ))?; + } Ok(()) } pub fn world_port_for_mode(root: &Path, mode: Mode) -> Result { let ports = crate::layout::read_ports(root); let s = state(root)?; - if s.simultaneous { - let second = s - .secondary_world_port - .ok_or_else(|| Error::Invalid("Second realm port is missing.".into()))?; - let port = match (s.active, mode) { - (Mode::Coa, Mode::Coa) => ports.world, - (Mode::Coa, Mode::Wildcard) => second, - (Mode::Wildcard, Mode::Wildcard) => ports.world, - (Mode::Wildcard, Mode::Coa) => second, - _ => { - return Err(Error::Invalid(format!( - "Unsupported mode {mode:?} for simultaneous hosting (active: {:?})", - s.active - ))) - } - }; - Ok(port) - } else if mode == s.active { - Ok(ports.world) - } else { - Err(Error::Invalid(format!( - "Realm mode {mode:?} is not active (active: {:?}) and simultaneous mode is disabled.", - s.active - ))) + if mode == s.active { + return Ok(ports.world); } + if mode == Mode::Reborn && s.with_reborn && s.active == Mode::Coa { + return s + .reborn_world_port + .ok_or_else(|| Error::Invalid("Warcraft Reborn realm port is missing.".into())); + } + if s.simultaneous && matches!((s.active, mode), (Mode::Coa, Mode::Wildcard) | (Mode::Wildcard, Mode::Coa)) { + return s + .secondary_world_port + .ok_or_else(|| Error::Invalid("Second realm port is missing.".into())); + } + Err(Error::Invalid(format!( + "Realm mode {mode:?} is not active (active: {:?}) and simultaneous mode is disabled.", + s.active + ))) } #[cfg(test)] @@ -838,6 +949,128 @@ mod tests { assert!(guard_module(t.path(), "content-scaling").is_ok()); } + fn write_class_model(root: &Path, text: &str) { + fsx::atomic_write(&root.join("Core/configs/modules/coa.conf"), text.as_bytes()).unwrap(); + } + + fn class_model(root: &Path) -> String { + let bytes = fs::read(root.join("Core/configs/modules/coa.conf")).unwrap(); + ConfFile::parse_bytes(&bytes) + .unwrap() + .get("CoA.ClassModel") + .unwrap_or_default() + .to_string() + } + + #[test] + fn a_class_model_that_does_not_fit_the_realm_is_replaced_by_the_realms_own() { + let t = tempfile::tempdir().unwrap(); + // No file: left to the server. No key: the server default "coa" applies, which fits Conquest of Azeroth. + align_class_model(t.path(), Mode::Coa).unwrap(); + assert!(!t.path().join("Core/configs/modules/coa.conf").exists()); + write_class_model(t.path(), "[worldserver] +# CoA.ClassModel = \"hero\" +"); + align_class_model(t.path(), Mode::Coa).unwrap(); + assert_eq!(class_model(t.path()), ""); + + write_class_model(t.path(), "# note +CoA.ClassModel = \"hero\" +CoA.GameModeMask = 0 +"); + align_class_model(t.path(), Mode::Coa).unwrap(); + assert_eq!( + fs::read_to_string(t.path().join("Core/configs/modules/coa.conf")).unwrap(), + "# note +CoA.ClassModel = \"coa\" +CoA.GameModeMask = 0 +" + ); + align_class_model(t.path(), Mode::Wildcard).unwrap(); + assert_eq!(class_model(t.path()), "\"hero\""); + align_class_model(t.path(), Mode::Reborn).unwrap(); + assert_eq!(class_model(t.path()), "\"wcr\""); + + // A missing key does not fit a realm other than Conquest of Azeroth. + write_class_model(t.path(), "[worldserver] +"); + align_class_model(t.path(), Mode::Wildcard).unwrap(); + assert_eq!(class_model(t.path()), "\"hero\""); + } + + #[test] + fn a_class_model_list_is_narrowed_to_the_realms_own_model() { + let t = tempfile::tempdir().unwrap(); + // The shipped default serves two models; the realm chosen in the Manager keeps only its own. + for (mode, want) in [(Mode::Coa, "\"coa\""), (Mode::Reborn, "\"wcr\"")] { + write_class_model(t.path(), "CoA.ClassModel = \"coa wcr\" +"); + align_class_model(t.path(), mode).unwrap(); + assert_eq!(class_model(t.path()), want); + } + // A setting that is already exactly right is not rewritten, quoted or not. + for (text, mode) in [ + ("CoA.ClassModel = \"coa\" +", Mode::Coa), + ("CoA.ClassModel = hero +", Mode::Wildcard), + ] { + write_class_model(t.path(), text); + align_class_model(t.path(), mode).unwrap(); + assert_eq!( + fs::read_to_string(t.path().join("Core/configs/modules/coa.conf")).unwrap(), + text + ); + } + } + + #[test] + fn before_start_aligns_the_class_model_with_the_active_realm() { + let t = tempfile::tempdir().unwrap(); + write_class_model(t.path(), "CoA.ClassModel = \"hero\" +"); + before_start(t.path()).unwrap(); + assert_eq!(class_model(t.path()), "\"coa\""); + } + + #[test] + fn extra_realms_follow_the_two_switches_and_old_states_still_load() { + let old: RealmState = serde_json::from_str( + r#"{"active":"coa","wildcard_created":true,"simultaneous":true,"secondary_world_port":8086}"#, + ) + .unwrap(); + assert!(!old.with_reborn && old.reborn_world_port.is_none()); + assert_eq!(old.extra_modes(), [Mode::Wildcard]); + let mut s = RealmState::default(); + assert!(s.extra_modes().is_empty()); + s.with_reborn = true; + assert_eq!(s.extra_modes(), [Mode::Reborn]); + s.simultaneous = true; + assert_eq!(s.extra_modes(), [Mode::Wildcard, Mode::Reborn]); + // Wildcard as the main realm still pairs with Conquest of Azeroth. + s.active = Mode::Wildcard; + s.with_reborn = false; + assert_eq!(s.extra_modes(), [Mode::Coa]); + } + + #[test] + fn each_running_realm_has_its_own_world_port() { + let t = tempfile::tempdir().unwrap(); + fsx::atomic_write(&t.path().join("Settings/repack.json"), br#"{"worldPort": 8085}"#).unwrap(); + let write = |json: &str| fsx::atomic_write(&t.path().join(STATE), json.as_bytes()).unwrap(); + write(r#"{"active":"coa","wildcard_created":true,"reborn_created":true,"simultaneous":true,"secondary_world_port":8086,"with_reborn":true,"reborn_world_port":8087}"#); + assert_eq!(world_port_for_mode(t.path(), Mode::Coa).unwrap(), 8085); + assert_eq!(world_port_for_mode(t.path(), Mode::Wildcard).unwrap(), 8086); + assert_eq!(world_port_for_mode(t.path(), Mode::Reborn).unwrap(), 8087); + // Only Reborn next to Conquest of Azeroth: Wildcard is not running anywhere. + write(r#"{"active":"coa","wildcard_created":true,"reborn_created":true,"with_reborn":true,"reborn_world_port":8087}"#); + assert_eq!(world_port_for_mode(t.path(), Mode::Reborn).unwrap(), 8087); + assert!(world_port_for_mode(t.path(), Mode::Wildcard).is_err()); + // Reborn only pairs with Conquest of Azeroth as the main realm. + write(r#"{"active":"wildcard","wildcard_created":true,"reborn_created":true,"with_reborn":true,"reborn_world_port":8087}"#); + assert!(world_port_for_mode(t.path(), Mode::Reborn).is_err()); + } + #[test] fn reborn_support_requires_the_new_code_in_the_installed_binary() { let t = tempfile::tempdir().unwrap(); diff --git a/crates/coa-core/src/update.rs b/crates/coa-core/src/update.rs index 1fa552d..f84ef2d 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -1851,9 +1851,8 @@ impl RepackEnv<'_> { if o.mysql.state == ServiceState::Running && o.auth.state == ServiceState::Running && o.world.state == ServiceState::Running - && o.secondary_world - .as_ref() - .is_none_or(|s| s.state == ServiceState::Running) + && o.extra_worlds() + .all(|s| s.state == ServiceState::Running) { let since = ready_since.get_or_insert_with(std::time::Instant::now); if since.elapsed() >= std::time::Duration::from_secs(10) { diff --git a/crates/coa-core/src/update_isolation.rs b/crates/coa-core/src/update_isolation.rs index 3e839d9..0ac1d23 100644 --- a/crates/coa-core/src/update_isolation.rs +++ b/crates/coa-core/src/update_isolation.rs @@ -18,6 +18,20 @@ const FILES: &[&str] = &[ ".realms/secondary/Core/configs/worldserver.conf", ".realms/secondary/Core/configs/authserver.conf", "Settings/realm-profiles/reborn.json", + ".realms/reborn/Settings/repack.json", + ".realms/reborn/Settings/realm-profile.json", + ".realms/reborn/Settings/worldserver.conf.template", + ".realms/reborn/Settings/authserver.conf.template", + ".realms/reborn/Core/configs/worldserver.conf", + ".realms/reborn/Core/configs/authserver.conf", +]; + +/// Ports of the extra worlds kept in the realm profile. +const REALM_PORT_KEYS: [&str; 4] = [ + "secondary_world_port", + "secondary_ra_port", + "reborn_world_port", + "reborn_ra_port", ]; #[derive(Serialize, Deserialize)] @@ -62,7 +76,7 @@ pub(crate) fn begin(root: &Path, meta: &Path) -> Result<()> { .collect(); if let Some(state) = &realm { excluded.extend( - ["secondary_world_port", "secondary_ra_port"] + REALM_PORT_KEYS .iter() .filter_map(|key| state[*key].as_u64()), ); @@ -82,7 +96,7 @@ pub(crate) fn begin(root: &Path, meta: &Path) -> Result<()> { settings[key] = reserve()?.into(); } if let Some(state) = &mut realm { - for key in ["secondary_world_port", "secondary_ra_port"] { + for key in REALM_PORT_KEYS { state[key] = reserve()?.into(); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 758ceb2..1057d5f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1169,6 +1169,17 @@ async fn realm_simultaneous( blocking(move || coa_core::multiworld::set_enabled(&root, enabled)).await } +#[tauri::command] +async fn realm_with_reborn( + state: State<'_, AppState>, + id: String, + enabled: bool, +) -> std::result::Result { + let root = path_of(&state, &id)?; + let _guard = BusyGuard::acquire(&state, &id)?; + blocking(move || coa_core::multiworld::set_reborn_enabled(&root, enabled)).await +} + #[tauri::command] async fn check_database( state: State<'_, AppState>, @@ -2322,6 +2333,7 @@ struct FriendsStatus { auth_port: u16, world_port: u16, secondary_world_port: Option, + reborn_world_port: Option, } /// Open one of a few known help pages in the default browser. Anything else is refused, so a page can never ask the @@ -2485,6 +2497,9 @@ async fn friends_status( secondary_world_port: coa_core::realms::state(&root)? .secondary_world_port .filter(|_| coa_core::realms::state(&root).is_ok_and(|s| s.simultaneous)), + reborn_world_port: coa_core::realms::state(&root)? + .reborn_world_port + .filter(|_| coa_core::realms::state(&root).is_ok_and(|s| s.with_reborn)), }) }) .await @@ -2542,7 +2557,13 @@ async fn friends_enable( let ports = layout::read_ports(&root); let mut note = None; let realms = coa_core::realms::state(&root)?; - let secondary = realms.secondary_world_port.filter(|_| realms.simultaneous); + let extra: Vec = [ + realms.secondary_world_port.filter(|_| realms.simultaneous), + realms.reborn_world_port.filter(|_| realms.with_reborn), + ] + .into_iter() + .flatten() + .collect(); let host = match mode { Mode::Local => "127.0.0.1".to_string(), Mode::Lan => coa_core::net::resolve_lan_host(lan_address_override.as_deref(), coa_core::net::lan_ip())?, @@ -2552,7 +2573,7 @@ async fn friends_enable( let open = mode != Mode::Local; let changed = friends::set_open(&root, &meta, open)?; if open && platform::flavor() == Flavor::Repack { - coa_core::firewall::ensure_rules_with_secondary(&ports, secondary)?; + coa_core::firewall::ensure_rules_with_extra(&ports, &extra)?; } if mode == Mode::Direct && use_upnp { match coa_core::upnp::discover() { @@ -2560,7 +2581,7 @@ async fn friends_enable( let lan = coa_core::net::lan_ip().ok_or_else(|| Error::Invalid("No local address.".into()))?; coa_core::upnp::add_mapping(&gw, ports.auth, lan, "Auth")?; coa_core::upnp::add_mapping(&gw, ports.world, lan, "World")?; - if let Some(port) = secondary { coa_core::upnp::add_mapping(&gw, port, lan, "Second world")?; } + for port in &extra { coa_core::upnp::add_mapping(&gw, *port, lan, "Extra world")?; } note = Some("Your router was asked to forward the game ports.".to_string()); } None => note = Some("Your router does not support automatic setup; forward the two game ports by hand or use the private network.".to_string()), @@ -2793,6 +2814,7 @@ pub fn run() { realm_profiles, realm_select, realm_simultaneous, + realm_with_reborn, check_database, repair_server, module_set_enabled, diff --git a/src/dev/mock.ts b/src/dev/mock.ts index 616aed6..887c503 100644 --- a/src/dev/mock.ts +++ b/src/dev/mock.ts @@ -11,6 +11,7 @@ export {}; let added = new URLSearchParams(location.search).has("demo"), running = false; let realm: "coa" | "wildcard" | "reborn" = "coa", wildcardCreated = false, rebornCreated = false; let simultaneous = false; + let withReborn = false; let remoteHost = localStorage.getItem("demo-remote-host") ?? ""; const moduleEdits: Record> = {}; const modState: Record = { "companions": true, "content-scaling": true, "client-compat": true, "tbc-content": true, "wotlk-content": true, "npc-enchanter": true }; @@ -27,18 +28,19 @@ 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,reborn_created:rebornCreated,reborn_supported:true,supported:true,recovery_pending:false,simultaneous,secondary_world_port:simultaneous?8086:null,secondary_running:simultaneous&&running}; - if(cmd==="realm_simultaneous") { if(running) throw new Error("Stop both realms before changing simultaneous startup."); simultaneous=args.enabled; wildcardCreated ||= simultaneous; return {active:realm,wildcard_created:wildcardCreated,reborn_created:rebornCreated,reborn_supported:true,supported:true,recovery_pending:false,simultaneous,secondary_world_port:simultaneous?8086:null,secondary_running:false}; } + if(cmd==="realm_profiles") return {active:realm,wildcard_created:wildcardCreated,reborn_created:rebornCreated,reborn_supported:true,supported:true,recovery_pending:false,simultaneous,secondary_world_port:simultaneous?8086:null,secondary_running:simultaneous&&running,with_reborn:withReborn,reborn_world_port:withReborn?8087:null,reborn_running:withReborn&&running}; + if(cmd==="realm_simultaneous") { if(running) throw new Error("Stop both realms before changing simultaneous startup."); simultaneous=args.enabled; wildcardCreated ||= simultaneous; return {active:realm,wildcard_created:wildcardCreated,reborn_created:rebornCreated,reborn_supported:true,supported:true,recovery_pending:false,simultaneous,secondary_world_port:simultaneous?8086:null,secondary_running:false,with_reborn:withReborn,reborn_world_port:withReborn?8087:null,reborn_running:false}; } + if(cmd==="realm_with_reborn") { if(running) throw new Error("Stop all realms before changing which realms start together."); withReborn=args.enabled; rebornCreated ||= withReborn; return {active:realm,wildcard_created:wildcardCreated,reborn_created:rebornCreated,reborn_supported:true,supported:true,recovery_pending:false,simultaneous,secondary_world_port:simultaneous?8086:null,secondary_running:false,with_reborn:withReborn,reborn_world_port:withReborn?8087:null,reborn_running:false}; } if(cmd==="check_database") return [{realm, migrations:[],problems:[],full_schema:false}]; if(cmd==="repair_server") { if(running) throw new Error("Stop the server before repairing it."); return {restored:[],applied:[],backup:"demo-before-repair",database:[{realm,migrations:[],problems:[],full_schema:false}],error:null}; } - if(cmd==="realm_select"){await new Promise(r=>setTimeout(r,600));realm=args.mode;rebornCreated ||= realm==="reborn";wildcardCreated ||= realm==="wildcard";modState.companions=realm==="coa";return {active:realm,wildcard_created:wildcardCreated,reborn_created:rebornCreated,reborn_supported:true,supported:true,recovery_pending:false,simultaneous,secondary_world_port:simultaneous?8086:null,secondary_running:simultaneous&&running};} + if(cmd==="realm_select"){await new Promise(r=>setTimeout(r,600));realm=args.mode;rebornCreated ||= realm==="reborn";wildcardCreated ||= realm==="wildcard";modState.companions=realm==="coa";return {active:realm,wildcard_created:wildcardCreated,reborn_created:rebornCreated,reborn_supported:true,supported:true,recovery_pending:false,simultaneous,secondary_world_port:simultaneous?8086:null,secondary_running:simultaneous&&running,with_reborn:withReborn,reborn_world_port:withReborn?8087:null,reborn_running:withReborn&&running};} if(cmd==="remote_connection") return {host:remoteHost,client_path:null}; if(cmd==="remote_connect") { remoteHost=args.host.trim(); localStorage.setItem("demo-remote-host",remoteHost); return {host:remoteHost,client_path:null}; } if(cmd==="play") return {ok:true,exit_code:null,code:null,human:null,output:""}; 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),secondary_world:simultaneous?svc("world-secondary",8086,s):null},busy:false,path_exists:true};} + 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),secondary_world:simultaneous?svc("world-secondary",8086,s):null,reborn_world:withReborn?svc("world-reborn",8087,s):null},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"; diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 070e8e1..51b2df5 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -19,11 +19,16 @@ const en = { "realm.profileLabel": "Realm", "realm.startupTitle": "Realm startup", "realm.secondWorld": "Second world", - "realm.simultaneous": "Start CoA and Wildcard together", - "realm.simultaneousHint": "Stop the server before changing this option. Start runs both worlds with separate characters, settings and ports, sharing accounts. Two worlds need more memory. The first setup creates Wildcard if needed.", + "realm.simultaneous": "Start Wildcard together with CoA", + "realm.withReborn": "Start Warcraft Reborn together with CoA", + "realm.rebornPort": "Warcraft Reborn world port: {port}", + "realm.rebornWorld": "Warcraft Reborn world", + "realm.rebornNeedsCoa": "Warcraft Reborn can start together with Conquest of Azeroth only. Select Conquest of Azeroth on Overview first.", + "realm.allThree": "Both options are on: Conquest of Azeroth, Wildcard and Warcraft Reborn start together.", + "realm.simultaneousHint": "Stop the server before changing these options. Start runs every chosen world with separate characters, settings and ports, sharing accounts. Each extra world needs more memory. The first setup creates the realm if needed.", "realm.secondPort": "Second world port: {port}", "realm.preparing": "Preparing realm databases…", - "realm.bothRunning": "Both worlds start together. Each realm keeps its own characters, progress and module settings.", + "realm.bothRunning": "These worlds start together. Each realm keeps its own characters, progress and module settings.", "repair.title": "Check and repair server", "repair.hint": "Stop both realms and update to the latest server package before repair. Repair backs up databases and replaced files, restores official program files and applies pending SQL. Your characters and existing settings are preserved.", "repair.check": "Check files and database", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 32d5647..ed5b593 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -22,11 +22,16 @@ const ru: Partial> = { "realm.profileLabel": "Реалм", "realm.startupTitle": "Запуск реалмов", "realm.secondWorld": "Второй мир", - "realm.simultaneous": "Запускать CoA и Wildcard одновременно", - "realm.simultaneousHint": "Остановите сервер перед изменением настройки. Запуск поднимет оба мира с отдельными персонажами, настройками и портами, но общими аккаунтами. Два мира требуют больше памяти. При первом включении Wildcard будет создан, если его ещё нет.", + "realm.simultaneous": "Запускать Wildcard вместе с CoA", + "realm.withReborn": "Запускать Warcraft Reborn вместе с CoA", + "realm.rebornPort": "Порт мира Warcraft Reborn: {port}", + "realm.rebornWorld": "Мир Warcraft Reborn", + "realm.rebornNeedsCoa": "Warcraft Reborn запускается только вместе с Conquest of Azeroth. Сначала выберите Conquest of Azeroth на главной.", + "realm.allThree": "Включены обе настройки: Conquest of Azeroth, Wildcard и Warcraft Reborn запускаются вместе.", + "realm.simultaneousHint": "Остановите сервер перед изменением настроек. Запуск поднимет каждый выбранный мир с отдельными персонажами, настройками и портами, но общими аккаунтами. Каждый дополнительный мир требует больше памяти. При первом включении реалм будет создан, если его ещё нет.", "realm.secondPort": "Порт второго мира: {port}", "realm.preparing": "Подготовка баз реалма…", - "realm.bothRunning": "Оба мира запускаются одновременно. У каждого свои персонажи, прогресс и настройки модулей.", + "realm.bothRunning": "Эти миры запускаются одновременно. У каждого свои персонажи, прогресс и настройки модулей.", "repair.title": "Проверка и восстановление сервера", "repair.hint": "Перед восстановлением остановите оба реалма и обновите сервер до последнего пакета. Repair сохранит резервную копию БД и заменяемых файлов, восстановит файлы программы и применит недостающие SQL. Персонажи и существующие настройки сохраняются.", "repair.check": "Проверить файлы и БД", diff --git a/src/lib/api.ts b/src/lib/api.ts index 68c55e0..123ecf7 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -48,13 +48,13 @@ export interface ServiceStatus { } export interface StatusView { - observed: { mysql: ServiceStatus; auth: ServiceStatus; world: ServiceStatus; secondary_world?: ServiceStatus | null }; + observed: { mysql: ServiceStatus; auth: ServiceStatus; world: ServiceStatus; secondary_world?: ServiceStatus | null; reborn_world?: ServiceStatus | null }; busy: boolean; path_exists: boolean; } export type RealmMode = "coa" | "wildcard" | "reborn"; -export interface RealmProfiles { active: RealmMode; wildcard_created: boolean; reborn_created: boolean; reborn_supported: boolean; supported: boolean; recovery_pending: boolean; simultaneous: boolean; secondary_world_port: number | null; secondary_running: boolean } +export interface RealmProfiles { active: RealmMode; wildcard_created: boolean; reborn_created: boolean; reborn_supported: boolean; supported: boolean; recovery_pending: boolean; simultaneous: boolean; secondary_world_port: number | null; secondary_running: boolean; with_reborn: boolean; reborn_world_port: number | null; reborn_running: boolean } export interface DatabaseCheck { realm: RealmMode; migrations: { id: string; db: string; status: "applied" | "pending" | "failed"; error: string | null }[]; @@ -429,6 +429,7 @@ export interface FriendsStatus { auth_port: number; world_port: number; secondary_world_port?: number | null; + reborn_world_port?: number | null; } export interface InternetCheck { @@ -472,6 +473,7 @@ export const api = { realmProfiles: (id: string) => invoke("realm_profiles", { id }), realmSelect: (id: string, mode: RealmMode, restart: boolean) => invoke("realm_select", { id, mode, restart }), realmSimultaneous: (id: string, enabled: boolean) => invoke("realm_simultaneous", { id, enabled }), + realmWithReborn: (id: string, enabled: boolean) => invoke("realm_with_reborn", { id, enabled }), checkDatabase: (id: string) => invoke("check_database", { id }), repairServer: (id: string) => invoke("repair_server", { id }), defaultInstallDir: () => invoke("default_install_dir"), diff --git a/src/screens/FriendsPage.tsx b/src/screens/FriendsPage.tsx index 6371e76..3c380a3 100644 --- a/src/screens/FriendsPage.tsx +++ b/src/screens/FriendsPage.tsx @@ -176,6 +176,7 @@ export function FriendsPage({ serverId }: { serverId: string }) { {st.secondary_world_port &&

{t("realm.secondPort", { port: st.secondary_world_port })}

} + {st.reborn_world_port &&

{t("realm.rebornPort", { port: st.reborn_world_port })}

}
diff --git a/src/screens/Overview.tsx b/src/screens/Overview.tsx index e3ac530..4d1f029 100644 --- a/src/screens/Overview.tsx +++ b/src/screens/Overview.tsx @@ -72,15 +72,15 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o const human = useHuman(); const [status, setStatus] = useState(null); // With both worlds started together, each realm gets its own card; the first realm owns the database and auth. - const [realms, setRealms] = useState<{ first: string; second: string } | null>(null); - const bothWorlds = !!status?.observed.secondary_world; + const [realms, setRealms] = useState<{ first: string; second: string; reborn: string } | null>(null); + const bothWorlds = !!status?.observed.secondary_world || !!status?.observed.reborn_world; useEffect(() => { if (!bothWorlds) { setRealms(null); return; } let alive = true; void api.realmProfiles(server.id).then((r) => { if (!alive) return; const name = (m: "coa" | "wildcard" | "reborn") => (m === "coa" ? "Conquest of Azeroth" : m === "reborn" ? "Warcraft Reborn" : "Wildcard"); - setRealms({ first: name(r.active), second: name(r.active === "coa" ? "wildcard" : "coa") }); + setRealms({ first: name(r.active), second: name(r.active === "coa" ? "wildcard" : "coa"), reborn: name("reborn") }); }).catch(() => { if (alive) setRealms(null); }); return () => { alive = false; }; }, [bothWorlds, server.id]); @@ -292,8 +292,8 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o ); } - const { mysql, auth, world, secondary_world } = status.observed; - const all = [mysql, auth, world, ...(secondary_world ? [secondary_world] : [])]; + const { mysql, auth, world, secondary_world, reborn_world } = status.observed; + const all = [mysql, auth, world, ...(secondary_world ? [secondary_world] : []), ...(reborn_world ? [reborn_world] : [])]; const running = all.every((s) => s.state === "running"); const anyUp = all.some((s) => s.state !== "stopped"); const transitioning = action !== null || status.busy || realmBusy; @@ -585,6 +585,28 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o )} + {reborn_world && ( + +
{realms ? `${realms.reborn} · ${t("overview.server")}` : t("realm.rebornWorld")}
+
+ + {reborn_world.state === "running" ? t("status.online") : reborn_world.state === "starting" ? t("status.starting") : t("status.offline")} +
+
+ +
+
+
{t("overview.uptime")}
+
{reborn_world.state === "running" ? formatUptime(reborn_world.uptime_secs) : "—"}
+
+

{t("realm.bothRunning")}

+
+ )} + {conflict && !anyUp && (

{t("overview.portTitle")}

diff --git a/src/screens/RealmPicker.tsx b/src/screens/RealmPicker.tsx index 54de2ca..563a0ed 100644 --- a/src/screens/RealmPicker.tsx +++ b/src/screens/RealmPicker.tsx @@ -13,6 +13,8 @@ export function RealmPicker({ serverId, running, disabled, onChanged, onBusy }: const [selected, setSelected] = useState("coa"); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + // More than one world starts together: the main realm cannot be switched then. + const together = !!view && (view.simultaneous || view.with_reborn); 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); }); @@ -34,18 +36,18 @@ export function RealmPicker({ serverId, running, disabled, onChanged, onBusy }:
- {view?.simultaneous ? Conquest of Azeroth + Wildcard : setSelected(e.target.value as RealmMode)} className="min-w-52 rounded-md border border-line bg-card-2 px-3 py-2 text-ink"> } - {view && !view.simultaneous && (selected !== view.active || view.recovery_pending) && } - {view && !view.simultaneous && {t("realm.active", { name: view.active === "coa" ? "CoA" : view.active === "reborn" ? "Warcraft Reborn" : "Wildcard" })}} + {view && !together && {t("realm.active", { name: view.active === "coa" ? "CoA" : view.active === "reborn" ? "Warcraft Reborn" : "Wildcard" })}}
-

{t(view?.simultaneous ? "realm.bothRunning" : "realm.separate")}

+

{t(together ? "realm.bothRunning" : "realm.separate")}

{selected === "reborn" &&

{t("realm.rebornModules")}

} {view && selected === "reborn" && !view.reborn_supported &&

{t("realm.rebornUpdate")}

} {selected === "wildcard" &&

{t("realm.modules")}

} diff --git a/src/screens/RealmStartupSettings.tsx b/src/screens/RealmStartupSettings.tsx index f84b1bb..2083205 100644 --- a/src/screens/RealmStartupSettings.tsx +++ b/src/screens/RealmStartupSettings.tsx @@ -13,20 +13,27 @@ export function RealmStartupSettings({ serverId }: { serverId: string }) { void api.realmProfiles(serverId).then(v => { if (alive) setView(v); }).catch(e => { if (alive) setError(asUiError(e).technical); }); return () => { alive = false; }; }, [serverId]); - async function change(enabled: boolean) { + async function change(realm: "wildcard" | "reborn", enabled: boolean) { setBusy(true); setError(null); - try { setView(await api.realmSimultaneous(serverId, enabled)); } + try { setView(await (realm === "wildcard" ? api.realmSimultaneous : api.realmWithReborn)(serverId, enabled)); } catch (e) { setError(asUiError(e).technical); } finally { setBusy(false); } } return

{t("realm.startupTitle")}

-

{t("realm.simultaneousHint")}

- {view?.simultaneous &&

{t("realm.secondPort", { port: view.secondary_world_port ?? "—" })}

} + {view?.simultaneous &&

{t("realm.secondPort", { port: view.secondary_world_port ?? "—" })}

} + + {view?.with_reborn &&

{t("realm.rebornPort", { port: view.reborn_world_port ?? "—" })}

} + {view && view.active !== "coa" && !view.with_reborn &&

{t("realm.rebornNeedsCoa")}

} + {view?.simultaneous && view.with_reborn &&

{t("realm.allThree")}

} +

{t("realm.simultaneousHint")}

{busy &&

{t("realm.preparing")}

} {error &&

{error}

}
; diff --git a/src/screens/RemoveServerCard.tsx b/src/screens/RemoveServerCard.tsx index f8edfcc..1919138 100644 --- a/src/screens/RemoveServerCard.tsx +++ b/src/screens/RemoveServerCard.tsx @@ -15,7 +15,7 @@ export function RemoveServerCard({ serverId, path, onForget }: { serverId: strin let active = true; void api.status(serverId).then((s) => { if (!active) return; - const all = [s.observed.mysql, s.observed.auth, s.observed.world, ...(s.observed.secondary_world ? [s.observed.secondary_world] : [])]; + const all = [s.observed.mysql, s.observed.auth, s.observed.world, ...(s.observed.secondary_world ? [s.observed.secondary_world] : []), ...(s.observed.reborn_world ? [s.observed.reborn_world] : [])]; setRunning(all.some((x) => x.state === "running" || x.state === "starting" || x.state === "stopping")); }).catch(() => { /* an unreadable status must not hide the button */ }); return () => { active = false; };