mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
925a81f4f2
commit
321ac597ce
24 files changed
+697
-173
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result<DriverO
|
||||
let observed = crate::process::observe(&root, &crate::layout::read_ports(&root));
|
||||
for service in [&observed.mysql, &observed.auth, &observed.world]
|
||||
.into_iter()
|
||||
.chain(observed.secondary_world.iter())
|
||||
.chain(observed.extra_worlds())
|
||||
{
|
||||
if let Some(conflict) = &service.conflict {
|
||||
output.push_str(&format!(
|
||||
|
||||
@@ -88,23 +88,27 @@ fn run_elevated(commands: &[String]) -> Result<()> {
|
||||
|
||||
/// Make sure both rules exist (adds only the missing ones) and confirm afterwards.
|
||||
pub fn ensure_rules(ports: &Ports) -> Result<Status> {
|
||||
ensure_rules_with_secondary(ports, None)
|
||||
ensure_rules_with_extra(ports, &[])
|
||||
}
|
||||
|
||||
pub fn ensure_rules_with_secondary(ports: &Ports, secondary: Option<u16>) -> Result<Status> {
|
||||
/// 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<Status> {
|
||||
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<String> = 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(
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
.join(",")
|
||||
);
|
||||
db.query(&realm_address_sql(host, local, &where_realms)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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<u16>, Option<u16>) {
|
||||
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<realms::View> {
|
||||
set_tree(main, Tree::Second, enabled)
|
||||
}
|
||||
|
||||
/// Start Warcraft Reborn together with Conquest of Azeroth.
|
||||
pub fn set_reborn_enabled(main: &Path, enabled: bool) -> Result<realms::View> {
|
||||
set_tree(main, Tree::Reborn, enabled)
|
||||
}
|
||||
|
||||
fn set_tree(main: &Path, tree: Tree, enabled: bool) -> Result<realms::View> {
|
||||
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<realms::View> {
|
||||
|| 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<u16>, near: u16| -> Result<u16> {
|
||||
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<realms::View> {
|
||||
.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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
)?;
|
||||
}
|
||||
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<PathBuf> {
|
||||
&RealmState {
|
||||
active: mode,
|
||||
wildcard_created: true,
|
||||
reborn_created: true,
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
@@ -256,12 +355,9 @@ fn secondary_launcher(source: &str) -> Result<String> {
|
||||
))
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -64,6 +64,16 @@ pub struct Observed {
|
||||
pub auth: ServiceStatus,
|
||||
pub world: ServiceStatus,
|
||||
pub secondary_world: Option<ServiceStatus>,
|
||||
/// The Warcraft Reborn world that starts together with Conquest of Azeroth.
|
||||
#[serde(default)]
|
||||
pub reborn_world: Option<ServiceStatus>,
|
||||
}
|
||||
|
||||
impl Observed {
|
||||
/// The worlds that run next to the primary one.
|
||||
pub fn extra_worlds(&self) -> impl Iterator<Item = &ServiceStatus> {
|
||||
self.secondary_world.iter().chain(self.reborn_world.iter())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_state_record(root: &Path, name: &str) -> Option<ProcessIdentity> {
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+283
-50
@@ -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<u16>,
|
||||
#[serde(default)]
|
||||
pub secondary_ra_port: Option<u16>,
|
||||
/// 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<u16>,
|
||||
#[serde(default)]
|
||||
pub reborn_ra_port: Option<u16>,
|
||||
}
|
||||
|
||||
impl RealmState {
|
||||
/// Realms that run as a world of their own next to the primary one.
|
||||
pub fn extra_modes(&self) -> Vec<Mode> {
|
||||
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<u16>,
|
||||
pub secondary_running: bool,
|
||||
pub with_reborn: bool,
|
||||
pub reborn_world_port: Option<u16>,
|
||||
pub reborn_running: bool,
|
||||
}
|
||||
|
||||
pub fn state(root: &Path) -> Result<RealmState> {
|
||||
@@ -139,7 +166,10 @@ pub fn view(root: &Path) -> Result<View> {
|
||||
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<View> {
|
||||
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<View> {
|
||||
"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<String> {
|
||||
(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<u16> {
|
||||
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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+25
-3
@@ -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<coa_core::realms::View, UiError> {
|
||||
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<u16>,
|
||||
reborn_world_port: Option<u16>,
|
||||
}
|
||||
|
||||
/// 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<u16> = [
|
||||
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,
|
||||
|
||||
+6
-4
@@ -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<string, Record<string, string>> = {};
|
||||
const modState: Record<string, boolean> = { "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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -22,11 +22,16 @@ const ru: Partial<Record<keyof typeof en, string>> = {
|
||||
"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": "Проверить файлы и БД",
|
||||
|
||||
+4
-2
@@ -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<RealmProfiles>("realm_profiles", { id }),
|
||||
realmSelect: (id: string, mode: RealmMode, restart: boolean) => invoke<RealmProfiles>("realm_select", { id, mode, restart }),
|
||||
realmSimultaneous: (id: string, enabled: boolean) => invoke<RealmProfiles>("realm_simultaneous", { id, enabled }),
|
||||
realmWithReborn: (id: string, enabled: boolean) => invoke<RealmProfiles>("realm_with_reborn", { id, enabled }),
|
||||
checkDatabase: (id: string) => invoke<DatabaseCheck[]>("check_database", { id }),
|
||||
repairServer: (id: string) => invoke<RepairReport>("repair_server", { id }),
|
||||
defaultInstallDir: () => invoke<string>("default_install_dir"),
|
||||
|
||||
@@ -176,6 +176,7 @@ export function FriendsPage({ serverId }: { serverId: string }) {
|
||||
</Line>
|
||||
</ul>
|
||||
{st.secondary_world_port && <p className="mt-3 text-sm text-muted">{t("realm.secondPort", { port: st.secondary_world_port })}</p>}
|
||||
{st.reborn_world_port && <p className="mt-3 text-sm text-muted">{t("realm.rebornPort", { port: st.reborn_world_port })}</p>}
|
||||
</Card>
|
||||
|
||||
<div className="mt-6 grid gap-3">
|
||||
|
||||
@@ -72,15 +72,15 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o
|
||||
const human = useHuman();
|
||||
const [status, setStatus] = useState<StatusView | null>(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
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{reborn_world && (
|
||||
<Card className="mt-4 p-7">
|
||||
<div className="mb-1 text-xs font-medium uppercase tracking-[0.14em] text-muted">{realms ? `${realms.reborn} · ${t("overview.server")}` : t("realm.rebornWorld")}</div>
|
||||
<div
|
||||
className={cn("flex items-center gap-3 text-3xl font-semibold", reborn_world.state === "running" ? "text-ok" : reborn_world.state === "starting" || transitioning ? "text-warn" : "text-muted")}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span aria-hidden className={cn("h-3 w-3 rounded-full", reborn_world.state === "running" ? "bg-ok" : reborn_world.state === "starting" || transitioning ? "bg-warn" : "bg-muted/50")} />
|
||||
{reborn_world.state === "running" ? t("status.online") : reborn_world.state === "starting" ? t("status.starting") : t("status.offline")}
|
||||
</div>
|
||||
<div className="mt-5 divide-y divide-line border-y border-line">
|
||||
<Row label={t("overview.world")} s={reborn_world} />
|
||||
</div>
|
||||
<dl className="mt-4 text-sm">
|
||||
<dt className="text-muted">{t("overview.uptime")}</dt>
|
||||
<dd className="mt-0.5 text-lg">{reborn_world.state === "running" ? formatUptime(reborn_world.uptime_secs) : "—"}</dd>
|
||||
</dl>
|
||||
<p className="mt-3 text-xs text-muted">{t("realm.bothRunning")}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{conflict && !anyUp && (
|
||||
<Card className="mt-4 border-warn/40 p-4" role="alert">
|
||||
<p className="font-medium text-warn">{t("overview.portTitle")}</p>
|
||||
|
||||
@@ -13,6 +13,8 @@ export function RealmPicker({ serverId, running, disabled, onChanged, onBusy }:
|
||||
const [selected, setSelected] = useState<RealmMode>("coa");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 }:
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Layers className="h-5 w-5 text-gold" aria-hidden />
|
||||
<label htmlFor="realm-profile" className="font-medium">{t("realm.profileLabel")}</label>
|
||||
{view?.simultaneous ? <span className="text-sm text-muted">Conquest of Azeroth + Wildcard</span> : <select id="realm-profile" value={selected} disabled={!view || disabled || busy} onChange={e => setSelected(e.target.value as RealmMode)} className="min-w-52 rounded-md border border-line bg-card-2 px-3 py-2 text-ink">
|
||||
{together ? <span className="text-sm text-muted">{[view.active, ...(view.simultaneous ? [view.active === "coa" ? "wildcard" : "coa"] : []), ...(view.with_reborn ? ["reborn"] : [])].map(m => m === "coa" ? "Conquest of Azeroth" : m === "reborn" ? "Warcraft Reborn" : "Wildcard").join(" + ")}</span> : <select id="realm-profile" value={selected} disabled={!view || disabled || busy} onChange={e => setSelected(e.target.value as RealmMode)} className="min-w-52 rounded-md border border-line bg-card-2 px-3 py-2 text-ink">
|
||||
<option value="coa">Conquest of Azeroth</option>
|
||||
<option value="wildcard" disabled={!view?.supported && !view?.wildcard_created}>Wildcard · Darkmoon</option>
|
||||
<option value="reborn" disabled={!view?.reborn_supported && !view?.reborn_created}>Warcraft Reborn</option>
|
||||
</select>}
|
||||
{view && !view.simultaneous && (selected !== view.active || view.recovery_pending) && <Button size="sm" disabled={disabled || busy} onClick={() => void select()}>
|
||||
{view && !together && (selected !== view.active || view.recovery_pending) && <Button size="sm" disabled={disabled || busy} onClick={() => void select()}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
|
||||
{busy ? t("realm.switching") : selected === "wildcard" && !view.wildcard_created ? t("realm.create") : selected === "reborn" && !view.reborn_created ? t("realm.rebornCreate") : running ? t("realm.restart") : t("realm.select")}
|
||||
</Button>}
|
||||
{view && !view.simultaneous && <span className="ml-auto text-xs text-muted">{t("realm.active", { name: view.active === "coa" ? "CoA" : view.active === "reborn" ? "Warcraft Reborn" : "Wildcard" })}</span>}
|
||||
{view && !together && <span className="ml-auto text-xs text-muted">{t("realm.active", { name: view.active === "coa" ? "CoA" : view.active === "reborn" ? "Warcraft Reborn" : "Wildcard" })}</span>}
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-muted">{t(view?.simultaneous ? "realm.bothRunning" : "realm.separate")}</p>
|
||||
<p className="mt-3 text-sm text-muted">{t(together ? "realm.bothRunning" : "realm.separate")}</p>
|
||||
{selected === "reborn" && <p className="mt-2 text-sm text-warn">{t("realm.rebornModules")}</p>}
|
||||
{view && selected === "reborn" && !view.reborn_supported && <p className="mt-2 text-sm text-warn">{t("realm.rebornUpdate")}</p>}
|
||||
{selected === "wildcard" && <p className="mt-2 text-sm text-warn">{t("realm.modules")}</p>}
|
||||
|
||||
@@ -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 <Card className="mt-6 p-6" aria-busy={busy}>
|
||||
<h2 className="font-semibold">{t("realm.startupTitle")}</h2>
|
||||
<label className="mt-3 flex items-center gap-3">
|
||||
<input type="checkbox" checked={view?.simultaneous ?? false} disabled={!view || busy || !view.supported || view.active === "reborn"} onChange={e => void change(e.target.checked)} />
|
||||
<input type="checkbox" checked={view?.simultaneous ?? false} disabled={!view || busy || !view.supported || view.active === "reborn"} onChange={e => void change("wildcard", e.target.checked)} />
|
||||
<span>{t("realm.simultaneous")}</span>
|
||||
</label>
|
||||
<p className="mt-2 text-sm text-muted">{t("realm.simultaneousHint")}</p>
|
||||
{view?.simultaneous && <p className="mt-2 text-sm text-muted">{t("realm.secondPort", { port: view.secondary_world_port ?? "—" })}</p>}
|
||||
{view?.simultaneous && <p className="mt-1 text-sm text-muted">{t("realm.secondPort", { port: view.secondary_world_port ?? "—" })}</p>}
|
||||
<label className="mt-3 flex items-center gap-3">
|
||||
<input type="checkbox" checked={view?.with_reborn ?? false} disabled={!view || busy || !view.reborn_supported || view.active !== "coa"} onChange={e => void change("reborn", e.target.checked)} />
|
||||
<span>{t("realm.withReborn")}</span>
|
||||
</label>
|
||||
{view?.with_reborn && <p className="mt-1 text-sm text-muted">{t("realm.rebornPort", { port: view.reborn_world_port ?? "—" })}</p>}
|
||||
{view && view.active !== "coa" && !view.with_reborn && <p className="mt-1 text-sm text-warn">{t("realm.rebornNeedsCoa")}</p>}
|
||||
{view?.simultaneous && view.with_reborn && <p className="mt-3 text-sm">{t("realm.allThree")}</p>}
|
||||
<p className="mt-3 text-sm text-muted">{t("realm.simultaneousHint")}</p>
|
||||
{busy && <p className="mt-2 text-sm" role="status">{t("realm.preparing")}</p>}
|
||||
{error && <p className="mt-3 text-sm text-bad" role="alert">{error}</p>}
|
||||
</Card>;
|
||||
|
||||
@@ -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; };
|
||||
|
||||
Reference in new issue
Block a user