diff --git a/TODO.md b/TODO.md index 3b6df58..d3bb4fe 100644 --- a/TODO.md +++ b/TODO.md @@ -27,3 +27,25 @@ 4. Анализировать последние строки `Core/Logs/Server.log` и `Errors.log` на критические маркеры (потеря соединения с MySQL, нехватка RAM/OOM, ошибка бинда портов). 5. Проверять по встроенному словарю известных сигнатур (паттерны частых крашей с понятным описанием, например: «Сбой удаления объекта из сетки карты», «Ошибка запроса к базе данных», «Нехватка оперативной памяти»). - *Fallback:* Если сигнатура не опознана в словаре известных проблем, показывать краткую техническую выжимку (код исключения + сработавший ассерт или вершина Call Stack) и кнопку разворота полного текста крашлога / копирования. + +## Модули и контент +- [ ] **Следующий билд: переключатель Dungeon Clear только для SQUID** (запрос владельца 2026-10-31): + - Модуль: https://github.com/Zyth45/mod-dungeon-clear, ветка `coa`; серверная интеграция описана в `C:/games/coa-server-build/TODO.md`. + - Добавить в каталог отдельный модуль Dungeon Clear, по умолчанию выключенный. На старых серверах без модуля показывать недоступным. + - Разрешать включение только при установленном и включённом SQUID (`playerbots`). При выключенном SQUID блокировать переключатель и объяснять причину; CoA Companions не подходят. + - Проверять зависимость также в Rust backend при изменении модуля и его настроек. Отключение SQUID/переход на Companions отключает Dungeon Clear; обратное включение SQUID не включает Dungeon Clear автоматически. + - Проверить сохранение после перезапуска, ручные конфиги и отказ включения через API в обход UI. Серверный guard обязателен независимо от Manager. + - Все новые UI-тексты — на шести языках. После реализации добавить changelog fragments в соответствующие компоненты; пока это запланированная, не выпущенная возможность. +- [ ] **Внедрение модуля гильдхаусов (`mod-guildhouse`)**: + - **Репозиторий:** `https://github.com/azerothcore/mod-guildhouse` + - **Пайплайн сборки (`coa-server-build`):** + - Добавить checkout модуля в `.github/workflows/server-binaries.yml`, `build.yml` и `build-linux.yml` в директорию `modules/mod-guildhouse`. + - Добавить лицензию модуля и копирование в `scripts/assemble-tree.ps1` (`Licenses/` и `NOTICE.txt`). + - **Адаптация под Conquest of Azeroth (CoA):** + - В диалоге дворецкого (`mod_guildhouse_butler.cpp`) вместо 10 классических тренеров WotLK спавнить **Book of Ascension** (entry `499992`), которая открывает интерфейс `mod-spellbook` и заменяет всех тренеров для кастомных классов и прогрессии способностей. + - Оставить без изменений: профессии, порталы в столицы, банк, аукционы (включая нейтральный), почту, вендоров, парикмахера, горн и наковальню. + - **База данных:** + - Миграции модуля (`guild_house`, `guild_house_spawns`, `creature_template`, `gameobject_template`, `mod_guildhouse_locale`) подхватываются `coa-release` автоматически при валидации схемы контракта. + - **В игре:** + - Разместить NPC-продавца гильдхаусов (`.npc add 500030`) в ключевых хабах / столицах (Штормград, Оргриммар, Даларан). + 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 6a6e08e..1741eb3 100644 --- a/crates/coa-core/src/friends.rs +++ b/crates/coa-core/src/friends.rs @@ -168,12 +168,16 @@ 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)?; - 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(&format!( "UPDATE acore_auth.realmlist SET address='{host}' WHERE {where_realms};" ))?; 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 9da99ed..0d21e99 100644 --- a/crates/coa-core/src/update.rs +++ b/crates/coa-core/src/update.rs @@ -1848,9 +1848,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/dev/mock.ts b/src/dev/mock.ts index 5806768..2d138c1 100644 --- a/src/dev/mock.ts +++ b/src/dev/mock.ts @@ -10,6 +10,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 }; @@ -26,18 +27,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 f27fa8e..538a9ef 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 34a14b9..09f8c92 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/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; };