mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
Add CoA / Wildcard realm profiles for v0.5.0 (#10)
* Add isolated Wildcard realm profiles and release v0.5.0 * Document realm validation and align npm lock version
This commit is contained in:
1 parent
831685dbd7
commit
1bcef5f8c2
35 files changed
+872
-37
No files matched your search
Generated
+3
-3
@@ -347,7 +347,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coa-core"
|
name = "coa-core"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -375,7 +375,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coa-release"
|
name = "coa-release"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"coa-core",
|
"coa-core",
|
||||||
@@ -384,7 +384,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coa-server-manager"
|
name = "coa-server-manager"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"coa-core",
|
"coa-core",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
|||||||
members = ["crates/coa-core", "crates/coa-release", "src-tauri"]
|
members = ["crates/coa-core", "crates/coa-release", "src-tauri"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "AGPL-3.0-only"
|
license = "AGPL-3.0-only"
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ no console, no config files, no database tools. Tauri 2 + Rust + React.
|
|||||||
into **UPDATE**. Updates are authenticated (Ed25519), back up first, keep files you changed and can be rolled back.
|
into **UPDATE**. Updates are authenticated (Ed25519), back up first, keep files you changed and can be rolled back.
|
||||||
* **Backups.** Characters, accounts and settings on demand, automatically before every update; verify and restore
|
* **Backups.** Characters, accounts and settings on demand, automatically before every update; verify and restore
|
||||||
from the app.
|
from the app.
|
||||||
|
* **CoA / Wildcard realms.** Choose the active realm on Overview. A server build with Wildcard support is required;
|
||||||
|
the first switch copies world data and creates an empty character database. Accounts are shared, characters and
|
||||||
|
module settings are separate, and only one world runs. Switching a running server saves characters and restarts it.
|
||||||
|
CoA companions are unavailable on Wildcard; other optional modules are experimental. Backups include both realms,
|
||||||
|
and database updates apply to both.
|
||||||
|
See [realm profiles and validation](docs/WILDCARD.md).
|
||||||
* **Companions (bots).** Fill the world with level-appropriate bots, see progress, stop spawning, take them offline,
|
* **Companions (bots).** Fill the world with level-appropriate bots, see progress, stop spawning, take them offline,
|
||||||
despawn N to relieve the server, and choose how many log in at server start.
|
despawn N to relieve the server, and choose how many log in at server start.
|
||||||
* **Realmlist switcher.** Keep named realmlists (for example *Solo* and *PTR*) and switch the game client between them from a
|
* **Realmlist switcher.** Keep named realmlists (for example *Solo* and *PTR*) and switch the game client between them from a
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
area: manager
|
||||||
|
type: added
|
||||||
|
audience: admins
|
||||||
|
title: Switch between CoA and Wildcard while keeping separate characters and module settings
|
||||||
|
---
|
||||||
|
Run one world at a time with shared accounts. Wildcard keeps its own progress; CoA companions are unavailable there, and other modules show experimental compatibility. Backups and server updates cover both realms.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
//! Native smoke checks for the disposable fixture created by tools/make-fixture.ps1.
|
||||||
|
//! Never point these probes at an owned server.
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use coa_core::db::{Account, Db};
|
||||||
|
use coa_core::driver::{self, Verb};
|
||||||
|
use coa_core::realms::{self, Mode};
|
||||||
|
use coa_core::{Error, Result};
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
let root = Path::new("C:/games/coa-wildcard-fixture");
|
||||||
|
let action = args.get(1).map(String::as_str).unwrap_or("view");
|
||||||
|
match action {
|
||||||
|
"select" => {
|
||||||
|
let mode = if args.get(2).map(String::as_str) == Some("wildcard") { Mode::Wildcard } else { Mode::Coa };
|
||||||
|
println!("{}", serde_json::to_string(&realms::select(root, mode)?)?);
|
||||||
|
}
|
||||||
|
"start" | "stop" => {
|
||||||
|
let out = driver::run(root, if action == "start" { Verb::StartAll } else { Verb::StopAll })?;
|
||||||
|
println!("{}", serde_json::to_string(&out)?);
|
||||||
|
if !out.ok { return Err(Error::Invalid("Fixture operation failed".into())); }
|
||||||
|
}
|
||||||
|
"probe" => {
|
||||||
|
let db = Db::from_repack(root, Account::Admin)?;
|
||||||
|
let s = realms::state(root)?;
|
||||||
|
println!("{}", db.query("SELECT * FROM acore_characters.manager_profile_probe;")?);
|
||||||
|
println!("active={} chars={} world={}", s.active.name(), s.active.schema("characters")?, s.active.schema("world")?);
|
||||||
|
}
|
||||||
|
"mark" => {
|
||||||
|
Db::from_repack(root, Account::Admin)?.query("INSERT INTO acore_characters.manager_profile_probe VALUES (22);")?;
|
||||||
|
}
|
||||||
|
"backup" => {
|
||||||
|
use coa_core::backup::{self, Kind, Trigger};
|
||||||
|
let meta = root.join("fixture-manager");
|
||||||
|
let point = backup::create(root, &meta, Kind::Full, Trigger::Manual, Some("realm isolation check".into()), &|step| println!("{step}"))?;
|
||||||
|
assert_eq!(point.realm, Mode::Wildcard);
|
||||||
|
for name in ["characters", "world", "auth", "coa-characters", "coa-world", "configs"] {
|
||||||
|
assert!(point.components.iter().any(|c| c.name == name), "Missing {name}");
|
||||||
|
}
|
||||||
|
assert!(backup::verify(&meta, &point.id)?.ok);
|
||||||
|
println!("Verified both realm backups: {}", point.id);
|
||||||
|
}
|
||||||
|
"database-checks" => {
|
||||||
|
use coa_core::backup::{self, Kind, Trigger};
|
||||||
|
use coa_core::manifest::{Manifest, Migration};
|
||||||
|
use coa_core::update::{Env, RepackEnv};
|
||||||
|
let meta = root.join("fixture-manager");
|
||||||
|
let env = RepackEnv { root, meta_dir: &meta };
|
||||||
|
env.ensure_stopped()?;
|
||||||
|
let staged = root.join("fixture-migrations");
|
||||||
|
let mut manifest: Manifest = serde_json::from_value(serde_json::json!({
|
||||||
|
"schema": 1, "kind": "update", "version": "0.5.0", "core": {"commit": null},
|
||||||
|
"builtAt": "fixture", "minManagerVersion": "0.5.0"
|
||||||
|
}))?;
|
||||||
|
for kind in ["characters", "world", "auth"] {
|
||||||
|
let sql = format!("CREATE TABLE acore_{kind}.manager_migration_probe (value INT); INSERT INTO acore_{kind}.manager_migration_probe VALUES (42);");
|
||||||
|
let path = staged.join(format!("{kind}/manager_realm_fixture.sql"));
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||||
|
std::fs::write(&path, sql)?;
|
||||||
|
manifest.migrations.push(Migration { id: "manager_realm_fixture".into(), db: kind.into(), sha256: coa_core::fsx::sha256_file(&path)?, destructive: false });
|
||||||
|
}
|
||||||
|
let result = env.migrate(&manifest, &staged)?;
|
||||||
|
assert!(result.failed.is_none());
|
||||||
|
backup::with_database(root, |db| {
|
||||||
|
for mode in [Mode::Coa, Mode::Wildcard] {
|
||||||
|
let realm = db.clone().for_realm(mode);
|
||||||
|
for kind in ["characters", "world", "auth"] {
|
||||||
|
assert_eq!(realm.query(&format!("SELECT COUNT(*) FROM acore_{kind}.manager_migration_probe;"))?, "1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
assert!(env.migrate(&manifest, &staged)?.applied.is_empty());
|
||||||
|
let point = backup::create(root, &meta, Kind::Quick, Trigger::Manual, None, &|_| {})?;
|
||||||
|
backup::with_database(root, |db| {
|
||||||
|
db.query("INSERT INTO acore_characters.manager_profile_probe VALUES (22);")?;
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
backup::restore_database(root, &meta, &point.id, "characters")?;
|
||||||
|
backup::with_database(root, |db| {
|
||||||
|
assert_eq!(db.query("SELECT COUNT(*) FROM acore_characters.manager_profile_probe;")?, "0");
|
||||||
|
assert_eq!(db.clone().for_realm(Mode::Coa).query("SELECT value FROM acore_characters.manager_profile_probe;")?, "11");
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
println!("Both realms migrated once; shared auth migrated once; Wildcard restore left CoA intact.");
|
||||||
|
}
|
||||||
|
"view" => println!("{}", serde_json::to_string(&realms::view(root)?)?),
|
||||||
|
_ => return Err(Error::Invalid("Unknown fixture action".into())),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -63,6 +63,8 @@ pub struct RecoveryPoint {
|
|||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub manager_version: String,
|
pub manager_version: String,
|
||||||
pub components: Vec<Component>,
|
pub components: Vec<Component>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub realm: crate::realms::Mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn backups_dir(meta: &Path) -> PathBuf {
|
fn backups_dir(meta: &Path) -> PathBuf {
|
||||||
@@ -127,6 +129,8 @@ pub fn config_files(root: &Path) -> Vec<String> {
|
|||||||
if root.join("RELEASE.json").is_file() {
|
if root.join("RELEASE.json").is_file() {
|
||||||
out.push("RELEASE.json".into());
|
out.push("RELEASE.json".into());
|
||||||
}
|
}
|
||||||
|
walk(root, &root.join("Settings/realm-profiles"), &mut out);
|
||||||
|
if root.join("Settings/realm-profile.json").is_file() { out.push("Settings/realm-profile.json".into()); }
|
||||||
out.sort();
|
out.sort();
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -191,16 +195,24 @@ pub fn create(root: &Path, meta: &Path, kind: Kind, trigger: Trigger, label: Opt
|
|||||||
|
|
||||||
let build = || -> Result<Vec<Component>> {
|
let build = || -> Result<Vec<Component>> {
|
||||||
let mut components = Vec::new();
|
let mut components = Vec::new();
|
||||||
for name in wanted_databases(kind) {
|
let realm = crate::realms::state(root)?;
|
||||||
let schema = db::schema_of(name)?;
|
let mut databases: Vec<(String, &str)> = wanted_databases(kind).iter()
|
||||||
|
.map(|name| Ok((name.to_string(), realm.active.schema(name)?))).collect::<Result<_>>()?;
|
||||||
|
if realm.wildcard_created && kind != Kind::Config {
|
||||||
|
let other = if realm.active == crate::realms::Mode::Coa { crate::realms::Mode::Wildcard } else { crate::realms::Mode::Coa };
|
||||||
|
databases.push((format!("{}-characters", other.name()), other.schema("characters")?));
|
||||||
|
if kind == Kind::Full { databases.push((format!("{}-world", other.name()), other.schema("world")?)); }
|
||||||
|
}
|
||||||
|
for (name, schema) in databases {
|
||||||
progress(&format!("Backing up {name} database"));
|
progress(&format!("Backing up {name} database"));
|
||||||
let component = with_database(root, |db| {
|
let component = with_database(root, |db| {
|
||||||
|
let db = db.clone().for_realm(crate::realms::Mode::Coa);
|
||||||
let size = db.schema_bytes(schema)?;
|
let size = db.schema_bytes(schema)?;
|
||||||
fsx::require_space(&partial, size / 2 + 32 * 1024 * 1024)?;
|
fsx::require_space(&partial, size / 2 + 32 * 1024 * 1024)?;
|
||||||
let tables = db.tables(schema)?.len();
|
let tables = db.tables(schema)?.len();
|
||||||
let file = format!("{name}.sql.zst");
|
let file = format!("{name}.sql.zst");
|
||||||
let (bytes, sha) = db.dump_to(schema, &partial.join(&file))?;
|
let (bytes, sha) = db.dump_to(schema, &partial.join(&file))?;
|
||||||
Ok(Component { name: (*name).into(), path: file, bytes, sha256: Some(sha), tables: Some(tables), files: None })
|
Ok(Component { name, path: file, bytes, sha256: Some(sha), tables: Some(tables), files: None })
|
||||||
})?;
|
})?;
|
||||||
components.push(component);
|
components.push(component);
|
||||||
}
|
}
|
||||||
@@ -219,6 +231,7 @@ pub fn create(root: &Path, meta: &Path, kind: Kind, trigger: Trigger, label: Opt
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let point = RecoveryPoint {
|
let point = RecoveryPoint {
|
||||||
|
realm: crate::realms::state(root)?.active,
|
||||||
schema: 1,
|
schema: 1,
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
kind,
|
kind,
|
||||||
@@ -289,6 +302,9 @@ pub fn delete(meta: &Path, id: &str) -> Result<()> {
|
|||||||
/// Put configuration files back. Files the backup does not contain are left alone. A safety point is taken first.
|
/// Put configuration files back. Files the backup does not contain are left alone. A safety point is taken first.
|
||||||
pub fn restore_configs(root: &Path, meta: &Path, id: &str) -> Result<RecoveryPoint> {
|
pub fn restore_configs(root: &Path, meta: &Path, id: &str) -> Result<RecoveryPoint> {
|
||||||
let point = get(meta, id)?;
|
let point = get(meta, id)?;
|
||||||
|
if point.realm != crate::realms::state(root)?.active {
|
||||||
|
return Err(Error::Invalid("Select the realm this backup belongs to before restoring it.".into()));
|
||||||
|
}
|
||||||
let comp = point.components.iter().find(|c| c.name == "configs").ok_or_else(|| Error::Invalid("this backup has no configuration".into()))?;
|
let comp = point.components.iter().find(|c| c.name == "configs").ok_or_else(|| Error::Invalid("this backup has no configuration".into()))?;
|
||||||
if !verify(meta, id)?.ok {
|
if !verify(meta, id)?.ok {
|
||||||
return Err(Error::Invalid("This backup is damaged and cannot be restored.".into()));
|
return Err(Error::Invalid("This backup is damaged and cannot be restored.".into()));
|
||||||
@@ -314,6 +330,9 @@ pub struct DbRestore {
|
|||||||
/// import into a staging schema, sanity-check it, then swap tables atomically and keep the old schema.
|
/// import into a staging schema, sanity-check it, then swap tables atomically and keep the old schema.
|
||||||
pub fn restore_database(root: &Path, meta: &Path, id: &str, name: &str) -> Result<DbRestore> {
|
pub fn restore_database(root: &Path, meta: &Path, id: &str, name: &str) -> Result<DbRestore> {
|
||||||
let point = get(meta, id)?;
|
let point = get(meta, id)?;
|
||||||
|
if point.realm != crate::realms::state(root)?.active {
|
||||||
|
return Err(Error::Invalid("Select the realm this backup belongs to before restoring it.".into()));
|
||||||
|
}
|
||||||
let comp = point.components.iter().find(|c| c.name == name && c.sha256.is_some()).ok_or_else(|| Error::Invalid(format!("this backup has no {name} database")))?;
|
let comp = point.components.iter().find(|c| c.name == name && c.sha256.is_some()).ok_or_else(|| Error::Invalid(format!("this backup has no {name} database")))?;
|
||||||
if !verify(meta, id)?.ok {
|
if !verify(meta, id)?.ok {
|
||||||
return Err(Error::Invalid("This backup is damaged and cannot be restored.".into()));
|
return Err(Error::Invalid("This backup is damaged and cannot be restored.".into()));
|
||||||
@@ -322,7 +341,7 @@ pub fn restore_database(root: &Path, meta: &Path, id: &str, name: &str) -> Resul
|
|||||||
if now.world.state != ServiceState::Stopped || now.auth.state != ServiceState::Stopped {
|
if now.world.state != ServiceState::Stopped || now.auth.state != ServiceState::Stopped {
|
||||||
return Err(Error::Invalid("Stop the server before restoring a database.".into()));
|
return Err(Error::Invalid("Stop the server before restoring a database.".into()));
|
||||||
}
|
}
|
||||||
let live = db::schema_of(name)?;
|
let live = if name.contains('-') { db::schema_of(name)? } else { point.realm.schema(name)? };
|
||||||
let expected_tables = comp.tables.unwrap_or(0);
|
let expected_tables = comp.tables.unwrap_or(0);
|
||||||
let dump = point_dir(meta, id)?.join(&comp.path);
|
let dump = point_dir(meta, id)?.join(&comp.path);
|
||||||
let stamp = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string();
|
let stamp = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string();
|
||||||
@@ -331,6 +350,7 @@ pub fn restore_database(root: &Path, meta: &Path, id: &str, name: &str) -> Resul
|
|||||||
let safety = create(root, meta, Kind::Database, Trigger::BeforeRestore, Some(format!("before restoring {name} from {id}")), &|_| {})?;
|
let safety = create(root, meta, Kind::Database, Trigger::BeforeRestore, Some(format!("before restoring {name} from {id}")), &|_| {})?;
|
||||||
|
|
||||||
with_database(root, |db| {
|
with_database(root, |db| {
|
||||||
|
let db = db.clone().for_realm(crate::realms::Mode::Coa);
|
||||||
let staging = format!("{live}_restore_{stamp}");
|
let staging = format!("{live}_restore_{stamp}");
|
||||||
let old = format!("{live}_before_restore_{stamp}");
|
let old = format!("{live}_before_restore_{stamp}");
|
||||||
if db.schema_exists(&staging)? || db.schema_exists(&old)? {
|
if db.schema_exists(&staging)? || db.schema_exists(&old)? {
|
||||||
|
|||||||
@@ -436,6 +436,7 @@ fn quiet_debug_loggers(root: &Path) -> Result<Vec<String>> {
|
|||||||
|
|
||||||
/// Apply validated `changes`. Nothing is written unless every change is valid; previous contents are snapshotted first.
|
/// Apply validated `changes`. Nothing is written unless every change is valid; previous contents are snapshotted first.
|
||||||
pub fn save(root: &Path, meta_dir: &Path, scope: Scope, changes: &BTreeMap<String, Value>) -> Result<SaveReport> {
|
pub fn save(root: &Path, meta_dir: &Path, scope: Scope, changes: &BTreeMap<String, Value>) -> Result<SaveReport> {
|
||||||
|
if scope == Scope::Bots { crate::realms::guard_module(root, "companions")?; }
|
||||||
let raws = validate(root, scope, changes)?;
|
let raws = validate(root, scope, changes)?;
|
||||||
let schema = scope.schema();
|
let schema = scope.schema();
|
||||||
let t = targets(root, scope)?;
|
let t = targets(root, scope)?;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ pub struct Db {
|
|||||||
port: u16,
|
port: u16,
|
||||||
user: &'static str,
|
user: &'static str,
|
||||||
password: Secret,
|
password: Secret,
|
||||||
|
realm: crate::realms::Mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -67,6 +68,8 @@ fn no_window(cmd: &mut Command) {
|
|||||||
pub const SCHEMAS: [(&str, &str); 3] = [("characters", "acore_characters"), ("auth", "acore_auth"), ("world", "acore_world")];
|
pub const SCHEMAS: [(&str, &str); 3] = [("characters", "acore_characters"), ("auth", "acore_auth"), ("world", "acore_world")];
|
||||||
|
|
||||||
pub fn schema_of(kind: &str) -> Result<&'static str> {
|
pub fn schema_of(kind: &str) -> Result<&'static str> {
|
||||||
|
if let Some(kind) = kind.strip_prefix("wildcard-") { return crate::realms::Mode::Wildcard.schema(kind); }
|
||||||
|
if let Some(kind) = kind.strip_prefix("coa-") { return crate::realms::Mode::Coa.schema(kind); }
|
||||||
SCHEMAS.iter().find(|(k, _)| *k == kind).map(|(_, s)| *s).ok_or_else(|| Error::Invalid(format!("unknown database {kind}")))
|
SCHEMAS.iter().find(|(k, _)| *k == kind).map(|(_, s)| *s).ok_or_else(|| Error::Invalid(format!("unknown database {kind}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +90,38 @@ impl Db {
|
|||||||
Account::Admin => ("root", creds.root_password),
|
Account::Admin => ("root", creds.root_password),
|
||||||
Account::App => ("acore", creds.app_password),
|
Account::App => ("acore", creds.app_password),
|
||||||
};
|
};
|
||||||
Ok(Db { bin, port: ports.mysql, user, password: Secret(password) })
|
Ok(Db { bin, port: ports.mysql, user, password: Secret(password), realm: crate::realms::state(root)?.active })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn for_realm(mut self, realm: crate::realms::Mode) -> Self { self.realm = realm; self }
|
||||||
|
|
||||||
|
pub fn realm_schema<'a>(&self, name: &'a str) -> &'a str {
|
||||||
|
if self.realm == crate::realms::Mode::Wildcard {
|
||||||
|
match name {
|
||||||
|
"acore_world" => "acore_world_wildcard",
|
||||||
|
"acore_characters" => "acore_characters_wildcard",
|
||||||
|
_ => name,
|
||||||
|
}
|
||||||
|
} else { name }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clone_structure(&self, source: &str, dest: &str, cache: &Path) -> Result<()> {
|
||||||
|
if !ident_ok(source) || !ident_ok(dest) { return Err(Error::Invalid("Invalid schema name.".into())); }
|
||||||
|
let path = cache.join("characters-structure.sql");
|
||||||
|
self.dump_structure_to(source, &path)?;
|
||||||
|
self.run_sql_file(dest, &path)?;
|
||||||
|
std::fs::remove_file(path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dump_structure_to(&self, source: &str, path: &Path) -> Result<()> {
|
||||||
|
if !ident_ok(source) { return Err(Error::Invalid("Invalid schema name.".into())); }
|
||||||
|
let mut c = self.command("mysqldump.exe");
|
||||||
|
c.args(["--no-data", "--no-tablespaces", "--skip-comments", "--default-character-set=utf8mb4", source]);
|
||||||
|
c.stdout(Stdio::from(File::create(&path)?));
|
||||||
|
let out = c.output()?;
|
||||||
|
if !out.status.success() { return Err(self.fail(&out.stderr)); }
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn command(&self, tool: &str) -> Command {
|
fn command(&self, tool: &str) -> Command {
|
||||||
@@ -123,9 +157,10 @@ impl Db {
|
|||||||
pub fn query(&self, sql: &str) -> Result<String> {
|
pub fn query(&self, sql: &str) -> Result<String> {
|
||||||
let mut c = self.command("mysql.exe");
|
let mut c = self.command("mysql.exe");
|
||||||
c.args(["--batch", "--skip-column-names", "--connect-timeout=5"]);
|
c.args(["--batch", "--skip-column-names", "--connect-timeout=5"]);
|
||||||
|
c.args(["--default-character-set=utf8mb4", "--max-allowed-packet=128M"]);
|
||||||
c.stdin(Stdio::piped());
|
c.stdin(Stdio::piped());
|
||||||
let mut child = c.spawn()?;
|
let mut child = c.spawn()?;
|
||||||
child.stdin.take().expect("piped").write_all(sql.as_bytes())?;
|
child.stdin.take().expect("piped").write_all(route_sql(sql, self.realm).as_bytes())?;
|
||||||
let out = child.wait_with_output()?;
|
let out = child.wait_with_output()?;
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
return Err(self.fail(&out.stderr));
|
return Err(self.fail(&out.stderr));
|
||||||
@@ -134,6 +169,7 @@ impl Db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn schema_exists(&self, name: &str) -> Result<bool> {
|
pub fn schema_exists(&self, name: &str) -> Result<bool> {
|
||||||
|
let name = self.realm_schema(name);
|
||||||
if !ident_ok(name) {
|
if !ident_ok(name) {
|
||||||
return Err(Error::Invalid("bad schema name".into()));
|
return Err(Error::Invalid("bad schema name".into()));
|
||||||
}
|
}
|
||||||
@@ -141,6 +177,7 @@ impl Db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn tables(&self, schema: &str) -> Result<Vec<String>> {
|
pub fn tables(&self, schema: &str) -> Result<Vec<String>> {
|
||||||
|
let schema = self.realm_schema(schema);
|
||||||
if !ident_ok(schema) {
|
if !ident_ok(schema) {
|
||||||
return Err(Error::Invalid("bad schema name".into()));
|
return Err(Error::Invalid("bad schema name".into()));
|
||||||
}
|
}
|
||||||
@@ -150,6 +187,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Bytes of data + index the schema occupies (used to estimate backup size).
|
/// Bytes of data + index the schema occupies (used to estimate backup size).
|
||||||
pub fn schema_bytes(&self, schema: &str) -> Result<u64> {
|
pub fn schema_bytes(&self, schema: &str) -> Result<u64> {
|
||||||
|
let schema = self.realm_schema(schema);
|
||||||
if !ident_ok(schema) {
|
if !ident_ok(schema) {
|
||||||
return Err(Error::Invalid("bad schema name".into()));
|
return Err(Error::Invalid("bad schema name".into()));
|
||||||
}
|
}
|
||||||
@@ -159,6 +197,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Stored routines / triggers / views make a table-level swap unsafe; count them.
|
/// Stored routines / triggers / views make a table-level swap unsafe; count them.
|
||||||
pub fn extra_objects(&self, schema: &str) -> Result<u64> {
|
pub fn extra_objects(&self, schema: &str) -> Result<u64> {
|
||||||
|
let schema = self.realm_schema(schema);
|
||||||
if !ident_ok(schema) {
|
if !ident_ok(schema) {
|
||||||
return Err(Error::Invalid("bad schema name".into()));
|
return Err(Error::Invalid("bad schema name".into()));
|
||||||
}
|
}
|
||||||
@@ -170,6 +209,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Consistent dump of one schema, zstd-compressed to `out`. Returns (compressed bytes, sha256 of the file).
|
/// Consistent dump of one schema, zstd-compressed to `out`. Returns (compressed bytes, sha256 of the file).
|
||||||
pub fn dump_to(&self, schema: &str, out: &Path) -> Result<(u64, String)> {
|
pub fn dump_to(&self, schema: &str, out: &Path) -> Result<(u64, String)> {
|
||||||
|
let schema = self.realm_schema(schema);
|
||||||
if !ident_ok(schema) {
|
if !ident_ok(schema) {
|
||||||
return Err(Error::Invalid("bad schema name".into()));
|
return Err(Error::Invalid("bad schema name".into()));
|
||||||
}
|
}
|
||||||
@@ -203,9 +243,15 @@ impl Db {
|
|||||||
|
|
||||||
/// Run a plain SQL file against `schema`; the first error aborts and is returned.
|
/// Run a plain SQL file against `schema`; the first error aborts and is returned.
|
||||||
pub fn run_sql_file(&self, schema: &str, file: &Path) -> Result<()> {
|
pub fn run_sql_file(&self, schema: &str, file: &Path) -> Result<()> {
|
||||||
|
let schema = self.realm_schema(schema);
|
||||||
if !ident_ok(schema) {
|
if !ident_ok(schema) {
|
||||||
return Err(Error::Invalid("bad schema name".into()));
|
return Err(Error::Invalid("bad schema name".into()));
|
||||||
}
|
}
|
||||||
|
if self.realm == crate::realms::Mode::Wildcard {
|
||||||
|
let sql = std::fs::read_to_string(file)?;
|
||||||
|
self.query(&format!("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; USE `{schema}`;\n{sql}"))?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let mut c = self.command("mysql.exe");
|
let mut c = self.command("mysql.exe");
|
||||||
c.args(["--default-character-set=utf8mb4", "--init-command=SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci", "--max-allowed-packet=128M"]);
|
c.args(["--default-character-set=utf8mb4", "--init-command=SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci", "--max-allowed-packet=128M"]);
|
||||||
c.arg(schema);
|
c.arg(schema);
|
||||||
@@ -219,6 +265,7 @@ impl Db {
|
|||||||
|
|
||||||
/// Import a dump produced by `dump_to` into `schema` (which must already exist and be empty).
|
/// Import a dump produced by `dump_to` into `schema` (which must already exist and be empty).
|
||||||
pub fn import_from(&self, schema: &str, dump: &Path) -> Result<()> {
|
pub fn import_from(&self, schema: &str, dump: &Path) -> Result<()> {
|
||||||
|
let schema = self.realm_schema(schema);
|
||||||
if !ident_ok(schema) {
|
if !ident_ok(schema) {
|
||||||
return Err(Error::Invalid("bad schema name".into()));
|
return Err(Error::Invalid("bad schema name".into()));
|
||||||
}
|
}
|
||||||
@@ -278,3 +325,50 @@ pub fn write_console_credentials(root: &Path, password: &str) -> Result<()> {
|
|||||||
pub fn valid_identifier(name: &str) -> bool {
|
pub fn valid_identifier(name: &str) -> bool {
|
||||||
ident_ok(name)
|
ident_ok(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Route only SQL identifiers. User names, values, comments and longer identifiers remain untouched.
|
||||||
|
fn route_sql(sql: &str, realm: crate::realms::Mode) -> String {
|
||||||
|
if realm == crate::realms::Mode::Coa { return sql.into(); }
|
||||||
|
let bytes = sql.as_bytes();
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
let start = i;
|
||||||
|
if matches!(bytes[i], b'\'' | b'"') {
|
||||||
|
let quote = bytes[i]; i += 1;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'\\' { i = (i + 2).min(bytes.len()); }
|
||||||
|
else if bytes[i] == quote {
|
||||||
|
i += 1;
|
||||||
|
if i < bytes.len() && bytes[i] == quote { i += 1; } else { break; }
|
||||||
|
} else { i += 1; }
|
||||||
|
}
|
||||||
|
out.push_str(&sql[start..i]);
|
||||||
|
} else if bytes[i] == b'#' || sql[i..].starts_with("--") || sql[i..].starts_with("/*") {
|
||||||
|
if sql[i..].starts_with("/*") {
|
||||||
|
i = sql[i + 2..].find("*/").map(|p| i + p + 4).unwrap_or(bytes.len());
|
||||||
|
} else { i = sql[i..].find('\n').map(|p| i + p).unwrap_or(bytes.len()); }
|
||||||
|
out.push_str(&sql[start..i]);
|
||||||
|
} else if bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_' {
|
||||||
|
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { i += 1; }
|
||||||
|
out.push_str(match &sql[start..i] {
|
||||||
|
"acore_world" => "acore_world_wildcard",
|
||||||
|
"acore_characters" => "acore_characters_wildcard",
|
||||||
|
other => other,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let c = sql[i..].chars().next().unwrap(); out.push(c); i += c.len_utf8();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod realm_tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn routes_schema_identifiers_without_rewriting_account_values() {
|
||||||
|
let sql = "SELECT * FROM `acore_characters`.characters WHERE name='acore_world' AND x=\"acore_characters\"; -- acore_world\nSELECT * FROM acore_world.items;";
|
||||||
|
assert_eq!(route_sql(sql, crate::realms::Mode::Wildcard), "SELECT * FROM `acore_characters_wildcard`.characters WHERE name='acore_world' AND x=\"acore_characters\"; -- acore_world\nSELECT * FROM acore_world_wildcard.items;");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,6 +80,17 @@ fn launcher(root: &Path) -> Result<(PathBuf, PathBuf)> {
|
|||||||
/// Run a launcher verb to completion (blocking; call from a worker thread).
|
/// Run a launcher verb to completion (blocking; call from a worker thread).
|
||||||
pub fn run(root: &Path, verb: Verb) -> Result<DriverOutcome> {
|
pub fn run(root: &Path, verb: Verb) -> Result<DriverOutcome> {
|
||||||
let root = fsx::canonicalize_lenient(root)?;
|
let root = fsx::canonicalize_lenient(root)?;
|
||||||
|
if verb == Verb::StartAll {
|
||||||
|
crate::realms::before_start(&root)?;
|
||||||
|
if root.join("Settings/realm-profile.json").exists() {
|
||||||
|
let db = crate::db::Db::from_repack(&root, crate::db::Account::Admin)?;
|
||||||
|
if !db.ping() {
|
||||||
|
let out = run(&root, Verb::StartMysql)?;
|
||||||
|
if !out.ok { return Ok(out); }
|
||||||
|
}
|
||||||
|
crate::realms::setup_realmlist(&root)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
let (python, script) = launcher(&root)?;
|
let (python, script) = launcher(&root)?;
|
||||||
let mut cmd = Command::new(&python);
|
let mut cmd = Command::new(&python);
|
||||||
cmd.arg("-B").arg(&script).arg(verb.arg()).current_dir(&root).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
cmd.arg("-B").arg(&script).arg(verb.arg()).current_dir(&root).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
|
|||||||
@@ -117,7 +117,8 @@ pub fn apply_realm_address(root: &Path, host: &str) -> Result<()> {
|
|||||||
return Err(Error::Invalid("That address is not valid.".into()));
|
return Err(Error::Invalid("That address is not valid.".into()));
|
||||||
}
|
}
|
||||||
let db = Db::from_repack(root, Account::Admin)?;
|
let db = Db::from_repack(root, Account::Admin)?;
|
||||||
db.query(&format!("UPDATE acore_auth.realmlist SET address='{host}', localAddress='{host}' WHERE id=1;"))?;
|
let realm = crate::realms::state(root)?.active.realm_id();
|
||||||
|
db.query(&format!("UPDATE acore_auth.realmlist SET address='{host}', localAddress='{host}' WHERE id={realm};"))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ pub mod ra;
|
|||||||
pub mod allsettings;
|
pub mod allsettings;
|
||||||
pub mod modules;
|
pub mod modules;
|
||||||
pub mod realmlist;
|
pub mod realmlist;
|
||||||
|
pub mod realms;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod release;
|
pub mod release;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ pub struct ModuleView {
|
|||||||
pub icon: String,
|
pub icon: String,
|
||||||
pub page: Option<String>,
|
pub page: Option<String>,
|
||||||
pub hidden: bool,
|
pub hidden: bool,
|
||||||
|
pub compatibility: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list(root: &Path) -> Vec<ModuleView> {
|
pub fn list(root: &Path) -> Vec<ModuleView> {
|
||||||
@@ -116,6 +117,7 @@ fn conf_name(root: &Path, e: &Entry) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn list_in(cat: &[Entry], root: &Path) -> Vec<ModuleView> {
|
fn list_in(cat: &[Entry], root: &Path) -> Vec<ModuleView> {
|
||||||
|
let wildcard = crate::realms::state(root).map(|s| s.active == crate::realms::Mode::Wildcard).unwrap_or(true);
|
||||||
cat.iter()
|
cat.iter()
|
||||||
.cloned()
|
.cloned()
|
||||||
.map(|e| {
|
.map(|e| {
|
||||||
@@ -126,14 +128,16 @@ fn list_in(cat: &[Entry], root: &Path) -> Vec<ModuleView> {
|
|||||||
// the active file decides; without one the documented default is what the server will use
|
// the active file decides; without one the documented default is what the server will use
|
||||||
let enabled = !e.switchable
|
let enabled = !e.switchable
|
||||||
|| read(&conf).or_else(|| read(&dist)).and_then(|c| c.get(&e.enable_key).map(truthy)).unwrap_or(e.default_on);
|
|| read(&conf).or_else(|| read(&dist)).and_then(|c| c.get(&e.enable_key).map(truthy)).unwrap_or(e.default_on);
|
||||||
|
let blocked = wildcard && matches!(e.id.as_str(), "companions" | "playerbots");
|
||||||
ModuleView {
|
ModuleView {
|
||||||
|
compatibility: if blocked { "unsupported" } else if wildcard && e.id != "client-compat" { "experimental" } else { "compatible" }.into(),
|
||||||
id: e.id,
|
id: e.id,
|
||||||
name: e.name,
|
name: e.name,
|
||||||
description: e.description,
|
description: e.description,
|
||||||
repo: e.repo,
|
repo: e.repo,
|
||||||
installed,
|
installed,
|
||||||
enabled,
|
enabled: enabled && !blocked,
|
||||||
switchable: e.switchable,
|
switchable: e.switchable && !blocked,
|
||||||
status: e.status,
|
status: e.status,
|
||||||
icon: e.icon,
|
icon: e.icon,
|
||||||
page: e.page,
|
page: e.page,
|
||||||
@@ -164,6 +168,7 @@ fn backup(meta: &Path, conf: &Path) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_enabled(root: &Path, meta: &Path, id: &str, on: bool) -> Result<()> {
|
pub fn set_enabled(root: &Path, meta: &Path, id: &str, on: bool) -> Result<()> {
|
||||||
|
if on { crate::realms::guard_module(root, id)?; }
|
||||||
set_enabled_in(&catalog(), root, meta, id, on)
|
set_enabled_in(&catalog(), root, meta, id, on)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +222,7 @@ fn settings_in(cat: &[Entry], root: &Path, id: &str) -> Result<Vec<Setting>> {
|
|||||||
|
|
||||||
/// Change values of settings the module already has. A value is one line of at most 500 characters.
|
/// Change values of settings the module already has. A value is one line of at most 500 characters.
|
||||||
pub fn save_settings(root: &Path, meta: &Path, id: &str, changes: &BTreeMap<String, String>) -> Result<Vec<String>> {
|
pub fn save_settings(root: &Path, meta: &Path, id: &str, changes: &BTreeMap<String, String>) -> Result<Vec<String>> {
|
||||||
|
crate::realms::guard_module(root, id)?;
|
||||||
save_settings_in(&catalog(), root, meta, id, changes)
|
save_settings_in(&catalog(), root, meta, id, changes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::config::parser::ConfFile;
|
||||||
|
use crate::db::{Account, Db};
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
use crate::{fsx, layout, process};
|
||||||
|
|
||||||
|
const STATE: &str = "Settings/realm-profile.json";
|
||||||
|
const JOURNAL: &str = "Settings/realm-switch.json";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Mode {
|
||||||
|
#[default]
|
||||||
|
Coa,
|
||||||
|
Wildcard,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mode {
|
||||||
|
pub fn name(self) -> &'static str {
|
||||||
|
match self { Self::Coa => "coa", Self::Wildcard => "wildcard" }
|
||||||
|
}
|
||||||
|
pub fn realm_id(self) -> u32 { if self == Self::Coa { 1 } else { 2 } }
|
||||||
|
pub fn schema(self, kind: &str) -> Result<&'static str> {
|
||||||
|
match (self, kind) {
|
||||||
|
(_, "auth") => Ok("acore_auth"),
|
||||||
|
(Self::Coa, "characters") => Ok("acore_characters"),
|
||||||
|
(Self::Coa, "world") => Ok("acore_world"),
|
||||||
|
(Self::Wildcard, "characters") => Ok("acore_characters_wildcard"),
|
||||||
|
(Self::Wildcard, "world") => Ok("acore_world_wildcard"),
|
||||||
|
_ => Err(Error::Invalid("Unknown realm database.".into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct RealmState {
|
||||||
|
pub active: Mode,
|
||||||
|
pub wildcard_created: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct View {
|
||||||
|
pub active: Mode,
|
||||||
|
pub wildcard_created: bool,
|
||||||
|
pub supported: bool,
|
||||||
|
pub recovery_pending: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state(root: &Path) -> Result<RealmState> {
|
||||||
|
if !root.join(STATE).exists() { return Ok(RealmState::default()); }
|
||||||
|
fsx::read_json(&root.join(STATE))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn view(root: &Path) -> Result<View> {
|
||||||
|
let s = state(root)?;
|
||||||
|
// Inspect the binary itself; templates or an old log cannot prove installed support.
|
||||||
|
let supported = fs::read(root.join("Core/worldserver.exe"))?
|
||||||
|
.windows(b"Wildcard synergy settings".len()).any(|w| w == b"Wildcard synergy settings");
|
||||||
|
Ok(View { active: s.active, wildcard_created: s.wildcard_created, supported, recovery_pending: root.join(JOURNAL).exists() })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn guard_module(root: &Path, id: &str) -> Result<()> {
|
||||||
|
if state(root)?.active == Mode::Wildcard && matches!(id, "companions" | "playerbots") {
|
||||||
|
return Err(Error::Invalid("CoA companion class mechanics have not been validated for Wildcard. This module is unavailable on this realm.".into()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
type Files = BTreeMap<String, Vec<u8>>;
|
||||||
|
|
||||||
|
fn configs(root: &Path) -> Result<Files> {
|
||||||
|
let mut files = Files::new();
|
||||||
|
for rel in crate::backup::config_files(root) {
|
||||||
|
if rel.starts_with("Core/configs/") && rel.ends_with(".conf")
|
||||||
|
|| rel.starts_with("Settings/") && rel.ends_with(".template") {
|
||||||
|
files.insert(rel.clone(), fs::read(fsx::safe_join(root, &rel)?)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot(root: &Path, mode: Mode) -> std::path::PathBuf {
|
||||||
|
root.join(format!("Settings/realm-profiles/{}.json", mode.name()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_configs(root: &Path, files: &Files) -> Result<()> {
|
||||||
|
for rel in configs(root)?.keys() {
|
||||||
|
if !files.contains_key(rel) { fs::remove_file(fsx::safe_join(root, rel)?)?; }
|
||||||
|
}
|
||||||
|
for (rel, bytes) in files {
|
||||||
|
if !(rel.starts_with("Core/configs/") && rel.ends_with(".conf")
|
||||||
|
|| rel.starts_with("Settings/") && rel.ends_with(".template")) {
|
||||||
|
return Err(Error::Invalid("Invalid realm configuration path.".into()));
|
||||||
|
}
|
||||||
|
fsx::atomic_write(&fsx::ensure_within(root, &fsx::safe_join(root, rel)?)?, bytes)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set(files: &mut Files, path: &str, key: &str, value: &str) -> Result<()> {
|
||||||
|
let mut conf = ConfFile::parse_bytes(files.entry(path.into()).or_insert_with(|| b"[worldserver]\n".to_vec()))?;
|
||||||
|
conf.set(key, value, &[]);
|
||||||
|
files.insert(path.into(), conf.to_text().into_bytes());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wildcard_configs(mut files: Files) -> Result<Files> {
|
||||||
|
for (key, value) in [
|
||||||
|
("CoA.ClassModel", "\"hero\""), ("CoA.GameModeMask", "64"),
|
||||||
|
("CoA.MapClass10ToWarrior", "0"), ("CoA.RealmType", "\"live seasonal\""),
|
||||||
|
("CoA.ClientBooleanConfigs", "\"CONFIG_LEGACY_CHARACTER_ADVANCEMENT_ENABLED=0\""),
|
||||||
|
] { set(&mut files, "Core/configs/modules/coa.conf", key, value)?; }
|
||||||
|
set(&mut files, "Core/configs/modules/mod-coa-challenges.conf", "CoAChallenges.GameModes.Realm", "WildCard")?;
|
||||||
|
set(&mut files, "Core/configs/modules/mod-coa-challenges.conf", "CoAChallenges.GameModes.Enable", "1")?;
|
||||||
|
for (key, value) in [("ChancePercent", "65"), ("LinkWeight", "3"), ("RelatedWeight", "2"), ("SpecTagWeight", "2"), ("SchoolTagWeight", "1"), ("TooltipWeight", "3"), ("TalentsNeedTarget", "1"), ("LogRolls", "1")] {
|
||||||
|
set(&mut files, "Core/configs/modules/wildcard.conf", &format!("Wildcard.Synergy.{key}"), value)?;
|
||||||
|
}
|
||||||
|
for path in ["Settings/worldserver.conf.template", "Core/configs/worldserver.conf"] {
|
||||||
|
if files.contains_key(path) {
|
||||||
|
for (key, value) in [("RealmID", "2"), ("AlwaysMaxSkillForLevel", "1"), ("PlayerStart.CustomSpells", "1")] {
|
||||||
|
set(&mut files, path, key, value)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (key, value) in [("CoaBots.Enable", "0"), ("CoaBots.AutoLoginOnStartup", "0")] {
|
||||||
|
set(&mut files, "Core/configs/modules/mod_coa_playerbots.conf", key, value)?;
|
||||||
|
}
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct Journal { before: RealmState, files: Files }
|
||||||
|
|
||||||
|
fn require_world_stopped(root: &Path) -> Result<()> {
|
||||||
|
let observed = process::observe(root, &layout::read_ports(root));
|
||||||
|
if observed.world.state != process::ServiceState::Stopped || observed.auth.state != process::ServiceState::Stopped {
|
||||||
|
return Err(Error::Invalid("Stop the server before switching realms. Your characters will be saved.".into()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn recover(root: &Path) -> Result<()> {
|
||||||
|
if !root.join(JOURNAL).exists() { return Ok(()); }
|
||||||
|
require_world_stopped(root)?;
|
||||||
|
let journal: Journal = fsx::read_json(&root.join(JOURNAL))?;
|
||||||
|
write_configs(root, &journal.files)?;
|
||||||
|
fsx::atomic_write_json(&root.join(STATE), &journal.before)?;
|
||||||
|
fs::remove_file(root.join(JOURNAL))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_databases(root: &Path) -> Result<()> {
|
||||||
|
crate::backup::with_database(root, |_| {
|
||||||
|
let db = Db::from_repack(root, Account::Admin)?.for_realm(Mode::Coa);
|
||||||
|
if db.query("SELECT COUNT(*) FROM acore_auth.realmlist WHERE id=2;")? != "0" {
|
||||||
|
return Err(Error::Invalid("Realm ID 2 is already in use. Its settings were left intact.".into()));
|
||||||
|
}
|
||||||
|
for kind in ["world", "characters"] {
|
||||||
|
let source = Mode::Coa.schema(kind)?;
|
||||||
|
let dest = Mode::Wildcard.schema(kind)?;
|
||||||
|
if db.schema_exists(dest)? {
|
||||||
|
return Err(Error::Invalid(format!("Database {dest} already exists. It was left intact; inspect it before creating a realm.")));
|
||||||
|
}
|
||||||
|
if db.extra_objects(source)? != 0 {
|
||||||
|
return Err(Error::Invalid("Realm creation does not support databases with routines, triggers or views.".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (schema, tables) in [
|
||||||
|
("acore_world", &["ascension_wildcard_boss_marks", "ascension_wildcard_tooltip_links"][..]),
|
||||||
|
("acore_characters", &["coa_wildcard_skill_card", "coa_wildcard_skill_card_pending", "coa_wildcard_skill_card_account", "coa_wildcard_skill_card_purchase", "coa_wildcard_specialization_cache"][..]),
|
||||||
|
] {
|
||||||
|
let present = db.tables(schema)?;
|
||||||
|
if tables.iter().any(|table| !present.iter().any(|p| p == table)) {
|
||||||
|
return Err(Error::Invalid("Wildcard database migrations are missing. Update the server database before creating this realm.".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cache = root.join("Settings/realm-profiles/staging");
|
||||||
|
fs::create_dir_all(&cache)?;
|
||||||
|
fsx::require_space(&cache, db.schema_bytes("acore_world")?.saturating_mul(2) + 512 * 1024 * 1024)?;
|
||||||
|
let dump = cache.join("world.sql.zst");
|
||||||
|
db.dump_to("acore_world", &dump)?;
|
||||||
|
let stage_world = format!("coa_realm_world_{}", uuid::Uuid::new_v4().simple());
|
||||||
|
let stage_chars = format!("coa_realm_chars_{}", uuid::Uuid::new_v4().simple());
|
||||||
|
db.query(&format!("CREATE DATABASE `{stage_world}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE DATABASE `{stage_chars}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"))?;
|
||||||
|
db.import_from(&stage_world, &dump)?;
|
||||||
|
db.clone_structure("acore_characters", &stage_chars, &cache)?;
|
||||||
|
for table in ["active_arena_season", "addons", "updates", "updates_include", "warden_action"] {
|
||||||
|
db.query(&format!("INSERT INTO `{stage_chars}`.`{table}` SELECT * FROM acore_characters.`{table}`;"))?;
|
||||||
|
}
|
||||||
|
// The newly empty realm inherits schema migration history, never the owner's characters or cards.
|
||||||
|
if db.tables("acore_world")?.iter().any(|t| t == "coa_manager_migrations") {
|
||||||
|
db.query(&format!("CREATE TABLE IF NOT EXISTS `{stage_world}`.coa_manager_migrations LIKE acore_world.coa_manager_migrations;"))?;
|
||||||
|
}
|
||||||
|
db.query("CREATE DATABASE acore_world_wildcard CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE DATABASE acore_characters_wildcard CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;")?;
|
||||||
|
let mut renames = Vec::new();
|
||||||
|
for (stage, dest) in [(&stage_world, "acore_world_wildcard"), (&stage_chars, "acore_characters_wildcard")] {
|
||||||
|
for table in db.tables(stage)? {
|
||||||
|
if !crate::db::valid_identifier(&table) { return Err(Error::Invalid("Unsupported table name.".into())); }
|
||||||
|
renames.push(format!("`{stage}`.`{table}` TO `{dest}`.`{table}`"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.query(&format!("RENAME TABLE {};", renames.join(", ")))?;
|
||||||
|
for host in db.query("SELECT host FROM mysql.user WHERE user='acore';")?.lines() {
|
||||||
|
let host = hex::encode(host.as_bytes());
|
||||||
|
// Host names are trusted database data, but still quote them through a hex SQL literal.
|
||||||
|
db.query(&format!("SET @host=CONVERT(UNHEX('{host}') USING utf8mb4); SET @grant=CONCAT('GRANT ALL ON acore_world_wildcard.* TO ', QUOTE('acore'), '@', QUOTE(@host)); PREPARE realm_grant FROM @grant; EXECUTE realm_grant; DEALLOCATE PREPARE realm_grant; SET @grant=CONCAT('GRANT ALL ON acore_characters_wildcard.* TO ', QUOTE('acore'), '@', QUOTE(@host)); PREPARE realm_grant FROM @grant; EXECUTE realm_grant; DEALLOCATE PREPARE realm_grant;"))?;
|
||||||
|
}
|
||||||
|
db.query(&format!("DROP DATABASE `{stage_world}`; DROP DATABASE `{stage_chars}`;"))?;
|
||||||
|
fs::remove_file(&dump)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select(root: &Path, mode: Mode) -> Result<View> {
|
||||||
|
require_world_stopped(root)?;
|
||||||
|
recover(root)?;
|
||||||
|
let mut s = state(root)?;
|
||||||
|
if mode == s.active { return view(root); }
|
||||||
|
if mode == Mode::Wildcard && !view(root)?.supported {
|
||||||
|
return Err(Error::Invalid("Update the server to a build with Wildcard support first.".into()));
|
||||||
|
}
|
||||||
|
// Verify launcher support before creating databases or changing the selected realm.
|
||||||
|
prepare_launcher(root)?;
|
||||||
|
let before = configs(root)?;
|
||||||
|
fsx::atomic_write_json(&snapshot(root, s.active), &before)?;
|
||||||
|
if mode == Mode::Wildcard && !s.wildcard_created {
|
||||||
|
fsx::atomic_write_json(&snapshot(root, mode), &wildcard_configs(before.clone())?)?;
|
||||||
|
create_databases(root)?;
|
||||||
|
s.wildcard_created = true;
|
||||||
|
fsx::atomic_write_json(&root.join(STATE), &s)?;
|
||||||
|
}
|
||||||
|
let target: Files = fsx::read_json(&snapshot(root, mode))?;
|
||||||
|
// The launcher and batch files share this lock; recheck after the potentially long database copy.
|
||||||
|
fs::create_dir_all(root.join(".state"))?;
|
||||||
|
let lock = fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(root.join(".state/control.lock"))?;
|
||||||
|
fs4::fs_std::FileExt::try_lock_exclusive(&lock).map_err(|_| Error::Invalid("Another start/stop action is in progress.".into()))?;
|
||||||
|
require_world_stopped(root)?;
|
||||||
|
let original = state(root)?;
|
||||||
|
fsx::atomic_write_json(&root.join(JOURNAL), &Journal { before: original, files: before })?;
|
||||||
|
let result = (|| {
|
||||||
|
write_configs(root, &target)?;
|
||||||
|
s.active = mode;
|
||||||
|
fsx::atomic_write_json(&root.join(STATE), &s)?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
if let Err(e) = result { drop(lock); recover(root)?; return Err(e); }
|
||||||
|
fs::remove_file(root.join(JOURNAL))?;
|
||||||
|
view(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adapt the bundled launcher's canonical schema and realm choices without changing its supervision logic.
|
||||||
|
pub fn prepare_launcher(root: &Path) -> Result<()> {
|
||||||
|
let path = root.join("Scripts/manage.py");
|
||||||
|
let source = fs::read_to_string(&path)?;
|
||||||
|
let patched = patch_launcher(&source)?;
|
||||||
|
if patched != source { fsx::atomic_write(&path, patched.as_bytes())?; }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn patch_launcher(source: &str) -> Result<String> {
|
||||||
|
if source.contains("# coa-manager-realm-profiles-v1") { return Ok(source.into()); }
|
||||||
|
let replacements = [
|
||||||
|
("(\"WorldDatabaseInfo\", \"acore_world\")", "(\"WorldDatabaseInfo\", \"acore_world_wildcard\" if coa_realm() == 'wildcard' else \"acore_world\")"),
|
||||||
|
("(\"CharacterDatabaseInfo\", \"acore_characters\")", "(\"CharacterDatabaseInfo\", \"acore_characters_wildcard\" if coa_realm() == 'wildcard' else \"acore_characters\")"),
|
||||||
|
("SET name='AzerothCore',address=", "SET name='{coa_realm_name()}',address="),
|
||||||
|
("WHERE id=1;", "WHERE id={coa_realm_id()};"),
|
||||||
|
("mysql(\"UPDATE acore_auth.realmlist SET flag=0", "mysql(f\"UPDATE acore_auth.realmlist SET flag=0"),
|
||||||
|
];
|
||||||
|
let mut out = source.to_string();
|
||||||
|
for (from, to) in replacements {
|
||||||
|
if !out.contains(from) { return Err(Error::Invalid("This launcher does not support realm profiles. Update the server package first.".into())); }
|
||||||
|
out = out.replace(from, to);
|
||||||
|
}
|
||||||
|
let insert = "# coa-manager-realm-profiles-v1\ndef coa_realm():\n path = ROOT / 'Settings/realm-profile.json'\n return json.loads(path.read_text(encoding='utf-8')).get('active', 'coa') if path.exists() else 'coa'\n\ndef coa_realm_id():\n return 2 if coa_realm() == 'wildcard' else 1\n\ndef coa_realm_name():\n return 'Wildcard' if coa_realm() == 'wildcard' else 'Conquest of Azeroth'\n\n";
|
||||||
|
// Insert before the entry point so ROOT, json and functions are all defined when called.
|
||||||
|
let marker = "if __name__ == \"__main__\":";
|
||||||
|
if !out.contains(marker) { return Err(Error::Invalid("Unrecognised server launcher entry point.".into())); }
|
||||||
|
Ok(out.replace(marker, &format!("{insert}{marker}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn before_start(root: &Path) -> Result<()> {
|
||||||
|
recover(root)?;
|
||||||
|
let s = state(root)?;
|
||||||
|
if !root.join(STATE).exists() { return Ok(()); }
|
||||||
|
prepare_launcher(root)?;
|
||||||
|
if s.active == Mode::Wildcard {
|
||||||
|
if !view(root)?.supported { return Err(Error::Invalid("The installed server build does not support Wildcard.".into())); }
|
||||||
|
let path = root.join("Core/configs/modules/mod_coa_playerbots.conf");
|
||||||
|
let mut conf = ConfFile::parse_bytes(&fs::read(&path)?)?;
|
||||||
|
conf.set("CoaBots.Enable", "0", &[]);
|
||||||
|
fsx::atomic_write(&path, conf.to_text().as_bytes())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn setup_realmlist(root: &Path) -> Result<()> {
|
||||||
|
let s = state(root)?;
|
||||||
|
if !root.join(STATE).exists() || !s.wildcard_created { return Ok(()); }
|
||||||
|
let db = Db::from_repack(root, Account::Admin)?;
|
||||||
|
let port = layout::read_ports(root).world;
|
||||||
|
db.query(&format!("INSERT INTO acore_auth.realmlist (id,name,address,localAddress,localSubnetMask,port,icon,flag,timezone,gamebuild) SELECT 2,'Wildcard',address,localAddress,localSubnetMask,{port},icon,2,timezone,gamebuild FROM acore_auth.realmlist WHERE id=1 ON DUPLICATE KEY UPDATE port={port}; UPDATE acore_auth.realmlist SET flag=2 WHERE id IN (1,2) AND id<>{};", s.active.realm_id()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fresh_wildcard_disables_bots_and_preserves_scaling() {
|
||||||
|
let mut f = Files::new();
|
||||||
|
f.insert("Core/configs/modules/mod-coa-content-scaling.conf".into(), b"[worldserver]\nCoAContentScaling.Enable = 1\n".to_vec());
|
||||||
|
f.insert("Settings/worldserver.conf.template".into(), b"[worldserver]\nRealmID = 1\n".to_vec());
|
||||||
|
let w = wildcard_configs(f.clone()).unwrap();
|
||||||
|
assert_eq!(w["Core/configs/modules/mod-coa-content-scaling.conf"], f["Core/configs/modules/mod-coa-content-scaling.conf"]);
|
||||||
|
let c = ConfFile::parse_bytes(&w["Core/configs/modules/mod_coa_playerbots.conf"]).unwrap();
|
||||||
|
assert_eq!(c.get("CoaBots.Enable"), Some("0"));
|
||||||
|
assert_eq!(ConfFile::parse_bytes(&w["Settings/worldserver.conf.template"]).unwrap().get("RealmID"), Some("2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_state_is_not_silently_treated_as_coa() {
|
||||||
|
let t = tempfile::tempdir().unwrap();
|
||||||
|
fs::create_dir(t.path().join("Settings")).unwrap();
|
||||||
|
fs::write(t.path().join(STATE), "broken").unwrap();
|
||||||
|
assert!(state(t.path()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_restores_original_settings() {
|
||||||
|
let t = tempfile::tempdir().unwrap();
|
||||||
|
let mut files = Files::new();
|
||||||
|
files.insert("Core/configs/modules/coa.conf".into(), b"CoA.ClassModel = coa\n".to_vec());
|
||||||
|
fsx::atomic_write_json(&t.path().join(JOURNAL), &Journal { before: RealmState::default(), files }).unwrap();
|
||||||
|
recover(t.path()).unwrap();
|
||||||
|
assert_eq!(state(t.path()).unwrap().active, Mode::Coa);
|
||||||
|
assert_eq!(fs::read_to_string(t.path().join("Core/configs/modules/coa.conf")).unwrap(), "CoA.ClassModel = coa\n");
|
||||||
|
assert!(!t.path().join(JOURNAL).exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reverting_to_coa_removes_wildcard_only_config_files() {
|
||||||
|
let t = tempfile::tempdir().unwrap();
|
||||||
|
let mut coa = Files::new();
|
||||||
|
coa.insert("Core/configs/modules/coa.conf".into(), b"[worldserver]\nCoA.ClassModel = coa\n".to_vec());
|
||||||
|
let wildcard = wildcard_configs(coa.clone()).unwrap();
|
||||||
|
write_configs(t.path(), &wildcard).unwrap();
|
||||||
|
write_configs(t.path(), &coa).unwrap();
|
||||||
|
assert!(!t.path().join("Core/configs/modules/mod-coa-challenges.conf").exists());
|
||||||
|
assert_eq!(configs(t.path()).unwrap(), coa);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wildcard_blocks_bot_settings_and_enable_calls() {
|
||||||
|
let t = tempfile::tempdir().unwrap();
|
||||||
|
fsx::atomic_write_json(&t.path().join(STATE), &RealmState { active: Mode::Wildcard, wildcard_created: true }).unwrap();
|
||||||
|
assert!(crate::modules::set_enabled(t.path(), t.path(), "companions", true).is_err());
|
||||||
|
assert!(crate::config::save(t.path(), t.path(), crate::config::Scope::Bots, &BTreeMap::new()).is_err());
|
||||||
|
assert!(guard_module(t.path(), "content-scaling").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_launcher_is_refused_without_partial_patching() {
|
||||||
|
assert!(patch_launcher("print('custom launcher')").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -476,9 +476,24 @@ impl Env for RepackEnv<'_> {
|
|||||||
fn migrate(&self, manifest: &Manifest, staged: &Path) -> Result<ApplyReport> {
|
fn migrate(&self, manifest: &Manifest, staged: &Path) -> Result<ApplyReport> {
|
||||||
let (root, meta) = (self.root, self.meta_dir);
|
let (root, meta) = (self.root, self.meta_dir);
|
||||||
crate::backup::with_database(root, |db| {
|
crate::backup::with_database(root, |db| {
|
||||||
crate::migrations::apply_pending(db, &manifest.migrations, staged, &|| {
|
let realms = crate::realms::state(root)?;
|
||||||
Ok(crate::backup::create(root, meta, crate::backup::Kind::Database, crate::backup::Trigger::BeforeMigration, None, &|_| {})?.id)
|
let mut result = crate::migrations::apply_pending(db, &manifest.migrations, staged, &|| {
|
||||||
})
|
Ok(crate::backup::create(root, meta, crate::backup::Kind::Full, crate::backup::Trigger::BeforeMigration, None, &|_| {})?.id)
|
||||||
|
})?;
|
||||||
|
if realms.wildcard_created && result.failed.is_none() {
|
||||||
|
let other = if realms.active == crate::realms::Mode::Coa { crate::realms::Mode::Wildcard } else { crate::realms::Mode::Coa };
|
||||||
|
let other_db = db.clone().for_realm(other);
|
||||||
|
let shared_snapshot = result.snapshot.clone();
|
||||||
|
let migrations: Vec<_> = manifest.migrations.iter().filter(|m| m.db != "auth").cloned().collect();
|
||||||
|
let extra = crate::migrations::apply_pending(&other_db, &migrations, staged, &|| {
|
||||||
|
if let Some(id) = &shared_snapshot { return Ok(id.clone()); }
|
||||||
|
Ok(crate::backup::create(root, meta, crate::backup::Kind::Full, crate::backup::Trigger::BeforeMigration, None, &|_| {})?.id)
|
||||||
|
})?;
|
||||||
|
result.applied.extend(extra.applied);
|
||||||
|
result.failed = extra.failed;
|
||||||
|
if result.snapshot.is_none() { result.snapshot = extra.snapshot; }
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Realm profiles
|
||||||
|
|
||||||
|
Overview offers CoA and Wildcard. The installed worldserver must contain Wildcard support and the database must
|
||||||
|
already have its Wildcard migrations. The Manager refuses an unsupported binary, an occupied realm ID 2 or an
|
||||||
|
existing destination database rather than overwrite them.
|
||||||
|
|
||||||
|
The first switch copies the CoA world into `acore_world_wildcard` and creates `acore_characters_wildcard` from
|
||||||
|
the character schema and neutral server seed tables. Existing characters, card collections and progress are not
|
||||||
|
copied. Accounts remain in shared `acore_auth`. Realm IDs 1 and 2 share the world port; only the selected world
|
||||||
|
runs, and the inactive realm is listed offline. A switch while running stops the server gracefully and restarts it.
|
||||||
|
|
||||||
|
Configuration profiles are saved under `Settings/realm-profiles`. An interrupted switch can restore its previous
|
||||||
|
configuration from the journal. CoA companion bots cannot be enabled on Wildcard. Content Scaling and the other
|
||||||
|
optional modules retain their settings but show experimental compatibility; class mechanics need separate gameplay
|
||||||
|
validation. Switching back restores the CoA module settings.
|
||||||
|
|
||||||
|
Quick/database backups include both character databases and shared auth. Full backups also include both worlds.
|
||||||
|
Recovery points identify their active realm; select that realm before restoring them. Server migrations apply to
|
||||||
|
both realms, with auth applied once. The server updater can replace its launcher: the Manager reapplies the realm
|
||||||
|
adapter before the next start.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Validation for v0.5.0
|
||||||
|
|
||||||
|
- `cargo test --workspace`: 178 core tests passed; command layer and examples compiled.
|
||||||
|
- `npm run build`, translation key checks and changelog validation passed.
|
||||||
|
- Native disposable fixture on offset ports: fresh Wildcard creation and startup, CoA startup after switching back,
|
||||||
|
separate probe values and restored CoA configuration. A second fresh creation verified the arena-season seed fix.
|
||||||
|
- Full backup verified both worlds, both character databases, shared auth and configurations.
|
||||||
|
- Native migration probe applied once to each realm and once to auth; repeating it did nothing.
|
||||||
|
- Restoring Wildcard characters removed a fixture-only change and preserved the CoA probe value.
|
||||||
|
- Core gameplay wrapper: `VERIFY ALL: PASSED`, gameplay stage only, real clock. First login (7 assertions),
|
||||||
|
level rolls/reroll (31), skill card rolls (13), stat path (12). The level-roll and skill-card scenarios passed on isolated
|
||||||
|
reruns after failing in the shared batch; the wrapper records two batch-sensitive cases.
|
||||||
|
- Browser demo verified active profile, hidden Bots navigation and module compatibility labels. Screenshots show
|
||||||
|
mock IPC; native checks above exercised the actual backend and server.
|
||||||
|
|
||||||
|
The gameplay harness required fixture-only synchronization of already applied migration history by verified SQL
|
||||||
|
hashes and its normal Windows module config path. Owned server files and databases were not modified.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "coa-server-manager",
|
"name": "coa-server-manager",
|
||||||
"version": "0.1.0",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "coa-server-manager",
|
"name": "coa-server-manager",
|
||||||
"version": "0.1.0",
|
"version": "0.5.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.12.0",
|
"@tauri-apps/api": "^2.12.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.8.0",
|
"@tauri-apps/plugin-dialog": "^2.8.0",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "coa-server-manager",
|
"name": "coa-server-manager",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+35
-1
@@ -189,6 +189,7 @@ async fn run_verb(state: &AppState, id: String, verb: Verb) -> std::result::Resu
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn start_server(state: State<'_, AppState>, id: String) -> std::result::Result<DriverOutcome, UiError> {
|
async fn start_server(state: State<'_, AppState>, id: String) -> std::result::Result<DriverOutcome, UiError> {
|
||||||
|
let _guard = BusyGuard::acquire(&state, &id)?;
|
||||||
// Servers installed before module configs were created automatically get them now (missing files only). Imported
|
// Servers installed before module configs were created automatically get them now (missing files only). Imported
|
||||||
// servers only get the module files they lack (see below); nothing they have is changed.
|
// servers only get the module files they lack (see below); nothing they have is changed.
|
||||||
if let Ok(root) = path_of(&state, &id) {
|
if let Ok(root) = path_of(&state, &id) {
|
||||||
@@ -209,12 +210,14 @@ async fn start_server(state: State<'_, AppState>, id: String) -> std::result::Re
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
let out = run_verb(&state, id.clone(), Verb::StartAll).await?;
|
let root = path_of(&state, &id)?;
|
||||||
|
let out = blocking(move || driver::run(&root, Verb::StartAll)).await?;
|
||||||
if out.ok {
|
if out.ok {
|
||||||
if let Ok(root) = path_of(&state, &id) {
|
if let Ok(root) = path_of(&state, &id) {
|
||||||
// Companions requested while the server was stopped are created now (once; a failure is only logged).
|
// Companions requested while the server was stopped are created now (once; a failure is only logged).
|
||||||
let r = root.clone();
|
let r = root.clone();
|
||||||
let _ = tauri::async_runtime::spawn_blocking(move || -> Result<()> {
|
let _ = tauri::async_runtime::spawn_blocking(move || -> Result<()> {
|
||||||
|
if coa_core::realms::guard_module(&r, "companions").is_err() { return Ok(()); }
|
||||||
let pending = meta_dir(&r)?.join("companions.pending.json");
|
let pending = meta_dir(&r)?.join("companions.pending.json");
|
||||||
if let Ok(v) = coa_core::fsx::read_json::<serde_json::Value>(&pending) {
|
if let Ok(v) = coa_core::fsx::read_json::<serde_json::Value>(&pending) {
|
||||||
let _ = std::fs::remove_file(&pending);
|
let _ = std::fs::remove_file(&pending);
|
||||||
@@ -278,6 +281,7 @@ async fn save_settings(
|
|||||||
changes: BTreeMap<String, Value>,
|
changes: BTreeMap<String, Value>,
|
||||||
) -> std::result::Result<config::SaveReport, UiError> {
|
) -> std::result::Result<config::SaveReport, UiError> {
|
||||||
let root = path_of(&state, &id)?;
|
let root = path_of(&state, &id)?;
|
||||||
|
let _guard = BusyGuard::acquire(&state, &id)?;
|
||||||
blocking(move || {
|
blocking(move || {
|
||||||
let meta = meta_dir(&root)?;
|
let meta = meta_dir(&root)?;
|
||||||
config::save(&root, &meta, scope, &changes)
|
config::save(&root, &meta, scope, &changes)
|
||||||
@@ -512,9 +516,35 @@ async fn modules_list(state: State<'_, AppState>, id: String) -> std::result::Re
|
|||||||
blocking(move || Ok(coa_core::modules::list(&root))).await
|
blocking(move || Ok(coa_core::modules::list(&root))).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn realm_profiles(state: State<'_, AppState>, id: String) -> std::result::Result<coa_core::realms::View, UiError> {
|
||||||
|
let root = path_of(&state, &id)?;
|
||||||
|
blocking(move || coa_core::realms::view(&root)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn realm_select(state: State<'_, AppState>, id: String, mode: coa_core::realms::Mode, restart: bool) -> std::result::Result<coa_core::realms::View, UiError> {
|
||||||
|
let root = path_of(&state, &id)?;
|
||||||
|
let _guard = BusyGuard::acquire(&state, &id)?;
|
||||||
|
let result = blocking(move || {
|
||||||
|
if restart {
|
||||||
|
let out = driver::run(&root, Verb::StopAll)?;
|
||||||
|
if !out.ok { return Err(Error::Invalid(out.output)); }
|
||||||
|
}
|
||||||
|
let view = coa_core::realms::select(&root, mode)?;
|
||||||
|
if restart {
|
||||||
|
let out = driver::run(&root, Verb::StartAll)?;
|
||||||
|
if !out.ok { return Err(Error::Invalid(format!("Realm selected, but startup failed: {}", out.output))); }
|
||||||
|
}
|
||||||
|
Ok(view)
|
||||||
|
}).await;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn module_set_enabled(state: State<'_, AppState>, id: String, module: String, enabled: bool) -> std::result::Result<(), UiError> {
|
async fn module_set_enabled(state: State<'_, AppState>, id: String, module: String, enabled: bool) -> std::result::Result<(), UiError> {
|
||||||
let root = path_of(&state, &id)?;
|
let root = path_of(&state, &id)?;
|
||||||
|
let _guard = BusyGuard::acquire(&state, &id)?;
|
||||||
blocking(move || {
|
blocking(move || {
|
||||||
let (dir, _) = install_meta(&root)?;
|
let (dir, _) = install_meta(&root)?;
|
||||||
coa_core::modules::set_enabled(&root, &dir, &module, enabled)
|
coa_core::modules::set_enabled(&root, &dir, &module, enabled)
|
||||||
@@ -531,6 +561,7 @@ async fn module_settings(state: State<'_, AppState>, id: String, module: String)
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn module_save_settings(state: State<'_, AppState>, id: String, module: String, changes: BTreeMap<String, String>) -> std::result::Result<Vec<String>, UiError> {
|
async fn module_save_settings(state: State<'_, AppState>, id: String, module: String, changes: BTreeMap<String, String>) -> std::result::Result<Vec<String>, UiError> {
|
||||||
let root = path_of(&state, &id)?;
|
let root = path_of(&state, &id)?;
|
||||||
|
let _guard = BusyGuard::acquire(&state, &id)?;
|
||||||
blocking(move || {
|
blocking(move || {
|
||||||
let (dir, _) = install_meta(&root)?;
|
let (dir, _) = install_meta(&root)?;
|
||||||
coa_core::modules::save_settings(&root, &dir, &module, &changes)
|
coa_core::modules::save_settings(&root, &dir, &module, &changes)
|
||||||
@@ -547,6 +578,7 @@ async fn all_settings(state: State<'_, AppState>, id: String) -> std::result::Re
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn all_settings_save(state: State<'_, AppState>, id: String, changes: BTreeMap<String, String>) -> std::result::Result<Vec<String>, UiError> {
|
async fn all_settings_save(state: State<'_, AppState>, id: String, changes: BTreeMap<String, String>) -> std::result::Result<Vec<String>, UiError> {
|
||||||
let root = path_of(&state, &id)?;
|
let root = path_of(&state, &id)?;
|
||||||
|
let _guard = BusyGuard::acquire(&state, &id)?;
|
||||||
blocking(move || {
|
blocking(move || {
|
||||||
let (dir, _) = install_meta(&root)?;
|
let (dir, _) = install_meta(&root)?;
|
||||||
coa_core::allsettings::save(&root, &dir, &changes)
|
coa_core::allsettings::save(&root, &dir, &changes)
|
||||||
@@ -1395,6 +1427,8 @@ pub fn run() {
|
|||||||
realmlist_delete,
|
realmlist_delete,
|
||||||
realmlist_activate,
|
realmlist_activate,
|
||||||
modules_list,
|
modules_list,
|
||||||
|
realm_profiles,
|
||||||
|
realm_select,
|
||||||
module_set_enabled,
|
module_set_enabled,
|
||||||
module_settings,
|
module_settings,
|
||||||
module_save_settings,
|
module_save_settings,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CoA Server Manager",
|
"productName": "CoA Server Manager",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"identifier": "com.coa.servermanager",
|
"identifier": "com.coa.servermanager",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|||||||
+7
-4
@@ -6,9 +6,10 @@ import serverPresets from "../../schemas/presets/server.json";
|
|||||||
export {};
|
export {};
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
let added = false, running = false;
|
let added = new URLSearchParams(location.search).has("demo"), running = false;
|
||||||
|
let realm: "coa" | "wildcard" = "coa", wildcardCreated = false;
|
||||||
const modState: Record<string, boolean> = { "companions": true, "content-scaling": true, "client-compat": true, "tbc-content": true, "wotlk-content": true, "npc-enchanter": true };
|
const modState: Record<string, boolean> = { "companions": true, "content-scaling": true, "client-compat": true, "tbc-content": true, "wotlk-content": true, "npc-enchanter": true };
|
||||||
const report = {path:"C:\games\CoA-Repack",classification:"healthy",items:[
|
const report = {path:"C:\\games\\CoA-Repack",classification:"healthy",items:[
|
||||||
{key:"worldserver",label:"World server",status:"found",detail:null},
|
{key:"worldserver",label:"World server",status:"found",detail:null},
|
||||||
{key:"authserver",label:"Auth server",status:"found",detail:null},
|
{key:"authserver",label:"Auth server",status:"found",detail:null},
|
||||||
{key:"database_content",label:"Auth / Characters / World databases",status:"found",detail:"acore_auth, acore_characters, acore_world"},
|
{key:"database_content",label:"Auth / Characters / World databases",status:"found",detail:"acore_auth, acore_characters, acore_world"},
|
||||||
@@ -21,13 +22,15 @@ export {};
|
|||||||
(window as any).__TAURI_INTERNALS__ = {
|
(window as any).__TAURI_INTERNALS__ = {
|
||||||
transformCallback: (cb: unknown)=>cb, unregisterCallback(){}, convertFileSrc:(x: unknown)=>x,
|
transformCallback: (cb: unknown)=>cb, unregisterCallback(){}, convertFileSrc:(x: unknown)=>x,
|
||||||
invoke: async (cmd: string, args?: any)=>{
|
invoke: async (cmd: string, args?: any)=>{
|
||||||
|
if(cmd==="realm_profiles") return {active:realm,wildcard_created:wildcardCreated,supported:true,recovery_pending:false};
|
||||||
|
if(cmd==="realm_select"){await new Promise(r=>setTimeout(r,600));realm=args.mode;wildcardCreated ||= realm==="wildcard";modState.companions=realm==="coa";return {active:realm,wildcard_created:wildcardCreated,supported:true,recovery_pending:false};}
|
||||||
if(cmd==="list_servers") return added?[{id:"1",name:"CoA-Repack",path:report.path}]:[];
|
if(cmd==="list_servers") return added?[{id:"1",name:"CoA-Repack",path:report.path}]:[];
|
||||||
if(cmd==="scan_server") return report;
|
if(cmd==="scan_server") return report;
|
||||||
if(cmd==="add_server"){added=true;return {id:"1",name:"CoA-Repack",path:report.path};}
|
if(cmd==="add_server"){added=true;return {id:"1",name:"CoA-Repack",path:report.path};}
|
||||||
if(cmd==="server_status"){const s=running?"running":"stopped";return {observed:{mysql:svc("mysql",3307,s),auth:svc("auth",3724,s),world:svc("world",8085,s)},busy:false,path_exists:true};}
|
if(cmd==="server_status"){const s=running?"running":"stopped";return {observed:{mysql:svc("mysql",3307,s),auth:svc("auth",3724,s),world:svc("world",8085,s)},busy:false,path_exists:true};}
|
||||||
if(cmd==="start_server"){await new Promise((r: any)=>setTimeout(r,1500));running=true;return {ok:true,exit_code:0,code:null,human:null,output:""};}
|
if(cmd==="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==="stop_server"){await new Promise((r: any)=>setTimeout(r,1500));running=false;return {ok:true,exit_code:0,code:null,human:null,output:""};}
|
||||||
if(cmd.startsWith("plugin:dialog")) return "C:\games\CoA-Repack";
|
if(cmd.startsWith("plugin:dialog")) return "C:\\games\\CoA-Repack";
|
||||||
if(cmd==="get_settings"){const sc=args?.scope==="server"?serverSchema:botsSchema;return {scope:args?.scope,categories:sc.categories,settings:(sc.settings as any[]).map(x=>({...x,options:x.options??[],value:x.default,is_default:true,present:true,problem:null,drift:false})),unknown_keys:3,drift_keys:[],files:[]};}
|
if(cmd==="get_settings"){const sc=args?.scope==="server"?serverSchema:botsSchema;return {scope:args?.scope,categories:sc.categories,settings:(sc.settings as any[]).map(x=>({...x,options:x.options??[],value:x.default,is_default:true,present:true,problem:null,drift:false})),unknown_keys:3,drift_keys:[],files:[]};}
|
||||||
if(cmd==="list_presets"){const pr=args?.scope==="server"?serverPresets:botsPresets;return (pr.presets as any[]).map(p=>({id:p.id,title:p.title,description:p.description}));}
|
if(cmd==="list_presets"){const pr=args?.scope==="server"?serverPresets:botsPresets;return (pr.presets as any[]).map(p=>({id:p.id,title:p.title,description:p.description}));}
|
||||||
if(cmd==="preview_preset"){const a=args;const sc=a.scope==="server"?serverSchema:botsSchema;const pr=(a.scope==="server"?serverPresets:botsPresets).presets as any[];const p=pr.find(x=>x.id===a.preset)??{id:a.preset??"defaults",title:"Recommended defaults",description:"Every setting returns to its default value."};const st=(sc.settings as any[]).filter(x=>x.type==="int"||x.type==="float"||x.type==="enum").slice(0,3);return {id:p.id,title:p.title,description:p.description,changes:st.map(x=>({key:x.key,title:x.title,from:x.default,to:x.type==="enum"?(x.options?.[1]?.value??x.default):(Number(x.default)||1)*2,dangerous:false}))};}
|
if(cmd==="preview_preset"){const a=args;const sc=a.scope==="server"?serverSchema:botsSchema;const pr=(a.scope==="server"?serverPresets:botsPresets).presets as any[];const p=pr.find(x=>x.id===a.preset)??{id:a.preset??"defaults",title:"Recommended defaults",description:"Every setting returns to its default value."};const st=(sc.settings as any[]).filter(x=>x.type==="int"||x.type==="float"||x.type==="enum").slice(0,3);return {id:p.id,title:p.title,description:p.description,changes:st.map(x=>({key:x.key,title:x.title,from:x.default,to:x.type==="enum"?(x.options?.[1]?.value??x.default):(Number(x.default)||1)*2,dangerous:false}))};}
|
||||||
@@ -56,7 +59,7 @@ export {};
|
|||||||
if(cmd==="realmlist_save"){const w=window as any;const rl=w.__rl;if(args.profileId){const p=rl.profiles.find((x: any)=>x.id===args.profileId);p.name=args.name;p.data=args.data;return p;}if(rl.profiles.some((x: any)=>x.name.toLowerCase()===String(args.name).toLowerCase()))throw {human:{code:"unknown",title:"",message:"",actions:[]},technical:"A realmlist with that name already exists."};const p={id:String(args.name).toLowerCase().replace(/[^a-z0-9]+/g,"-"),name:args.name,data:(/\s/.test(args.data.trim())?args.data.trim():"set realmlist "+args.data.trim())+"\r\n"};rl.profiles.push(p);return p;}
|
if(cmd==="realmlist_save"){const w=window as any;const rl=w.__rl;if(args.profileId){const p=rl.profiles.find((x: any)=>x.id===args.profileId);p.name=args.name;p.data=args.data;return p;}if(rl.profiles.some((x: any)=>x.name.toLowerCase()===String(args.name).toLowerCase()))throw {human:{code:"unknown",title:"",message:"",actions:[]},technical:"A realmlist with that name already exists."};const p={id:String(args.name).toLowerCase().replace(/[^a-z0-9]+/g,"-"),name:args.name,data:(/\s/.test(args.data.trim())?args.data.trim():"set realmlist "+args.data.trim())+"\r\n"};rl.profiles.push(p);return p;}
|
||||||
if(cmd==="realmlist_delete"){const rl=(window as any).__rl;rl.profiles=rl.profiles.filter((x: any)=>x.id!==args.profileId);return null;}
|
if(cmd==="realmlist_delete"){const rl=(window as any).__rl;rl.profiles=rl.profiles.filter((x: any)=>x.id!==args.profileId);return null;}
|
||||||
if(cmd==="account_delete") return null;
|
if(cmd==="account_delete") return null;
|
||||||
if(cmd==="modules_list") return [{id:"companions",name:"CoA Companions",description:{en:"Companion bots for groups, dungeons and your guild.",ru:"Боты-компаньоны для групп, подземелий и гильдии."},repo:"https://github.com/Corfirean/mod-coa-playerbots",installed:true,enabled:modState["companions"],switchable:true,status:"beta",icon:"bot",page:"bots"},{id:"content-scaling",name:"Content Scaling",description:{en:"Scales dungeons and raids to your group, with solo modes.",ru:"Подстраивает подземелья и рейды под вашу группу, есть соло-режимы."},repo:"https://github.com/Corfirean/mod-coa-content-scaling",installed:true,enabled:modState["content-scaling"],switchable:true,status:"early",icon:"scaling",page:null},{id:"tbc-content",name:"TBC Content",description:{en:"Outland dungeons and raids, scaled to your level range.",ru:"Подземелья и рейды Запределья, подогнанные под ваши уровни."},repo:"https://github.com/Corfirean/mod-coa-content-scaling/tree/main/content-packs/mod-coa-tbc-content",installed:true,enabled:modState["tbc-content"],switchable:true,status:"early",icon:"tbc",page:null},{id:"wotlk-content",name:"WotLK Content",description:{en:"Northrend dungeons and raids, scaled to your level range.",ru:"Подземелья и рейды Нордскола, подогнанные под ваши уровни."},repo:"https://github.com/Corfirean/mod-coa-content-scaling/tree/main/content-packs/mod-coa-wotlk-content",installed:true,enabled:modState["wotlk-content"],switchable:true,status:"early",icon:"wotlk",page:null},{id:"ah-bot",name:"Auction House Bot",description:{en:"Fills the auction house with realistic, adjustable listings.",ru:"Наполняет аукцион реалистичными настраиваемыми лотами."},repo:"https://github.com/NathanHandley/mod-ah-bot-plus",installed:false,enabled:false,switchable:false,status:"soon",icon:"gavel",page:null,hidden:false},{id:"npc-enchanter",name:"NPC Enchanter",description:{en:"An NPC that enchants your gear for you.",ru:"НПС, который зачаровывает ваше снаряжение."},repo:"https://github.com/azerothcore/mod-npc-enchanter",installed:true,enabled:modState["npc-enchanter"],switchable:true,status:"early",icon:"sparkles",page:null,hidden:false},{id:"playerbots",name:"SQUID's Playerbots",description:{en:"An alternative bot system with random bots and full AI.",ru:"Альтернативная система ботов со случайными ботами и полным ИИ."},repo:"https://github.com/Zyth45/mod-playerbots",installed:false,enabled:false,switchable:false,status:"soon",icon:"swords",page:null,hidden:false},{id:"client-compat",name:"Client compatibility",description:{en:"Client-server link and appearance unlocks.",ru:"Связь клиента с сервером и разблокировка внешних видов."},repo:"https://github.com/Corfirean/azerothcore-wotlk-coa",installed:true,enabled:modState["client-compat"],switchable:false,status:"release",icon:"plug",page:null,hidden:true}];
|
if(cmd==="modules_list") return [{id:"companions",name:"CoA Companions",description:{en:"Companion bots for groups, dungeons and your guild.",ru:"Боты-компаньоны для групп, подземелий и гильдии."},repo:"https://github.com/Corfirean/mod-coa-playerbots",installed:true,enabled:modState["companions"],switchable:true,status:"beta",icon:"bot",page:"bots"},{id:"content-scaling",name:"Content Scaling",description:{en:"Scales dungeons and raids to your group, with solo modes.",ru:"Подстраивает подземелья и рейды под вашу группу, есть соло-режимы."},repo:"https://github.com/Corfirean/mod-coa-content-scaling",installed:true,enabled:modState["content-scaling"],switchable:true,status:"early",icon:"scaling",page:null},{id:"tbc-content",name:"TBC Content",description:{en:"Outland dungeons and raids, scaled to your level range.",ru:"Подземелья и рейды Запределья, подогнанные под ваши уровни."},repo:"https://github.com/Corfirean/mod-coa-content-scaling/tree/main/content-packs/mod-coa-tbc-content",installed:true,enabled:modState["tbc-content"],switchable:true,status:"early",icon:"tbc",page:null},{id:"wotlk-content",name:"WotLK Content",description:{en:"Northrend dungeons and raids, scaled to your level range.",ru:"Подземелья и рейды Нордскола, подогнанные под ваши уровни."},repo:"https://github.com/Corfirean/mod-coa-content-scaling/tree/main/content-packs/mod-coa-wotlk-content",installed:true,enabled:modState["wotlk-content"],switchable:true,status:"early",icon:"wotlk",page:null},{id:"ah-bot",name:"Auction House Bot",description:{en:"Fills the auction house with realistic, adjustable listings.",ru:"Наполняет аукцион реалистичными настраиваемыми лотами."},repo:"https://github.com/NathanHandley/mod-ah-bot-plus",installed:false,enabled:false,switchable:false,status:"soon",icon:"gavel",page:null,hidden:false},{id:"npc-enchanter",name:"NPC Enchanter",description:{en:"An NPC that enchants your gear for you.",ru:"НПС, который зачаровывает ваше снаряжение."},repo:"https://github.com/azerothcore/mod-npc-enchanter",installed:true,enabled:modState["npc-enchanter"],switchable:true,status:"early",icon:"sparkles",page:null,hidden:false},{id:"playerbots",name:"SQUID's Playerbots",description:{en:"An alternative bot system with random bots and full AI.",ru:"Альтернативная система ботов со случайными ботами и полным ИИ."},repo:"https://github.com/Zyth45/mod-playerbots",installed:false,enabled:false,switchable:false,status:"soon",icon:"swords",page:null,hidden:false},{id:"client-compat",name:"Client compatibility",description:{en:"Client-server link and appearance unlocks.",ru:"Связь клиента с сервером и разблокировка внешних видов."},repo:"https://github.com/Corfirean/azerothcore-wotlk-coa",installed:true,enabled:modState["client-compat"],switchable:false,status:"release",icon:"plug",page:null,hidden:true}].map(m => ({...m, compatibility: realm === "wildcard" ? (["companions", "playerbots"].includes(m.id) ? "unsupported" : m.id === "client-compat" ? "compatible" : "experimental") : "compatible", switchable: realm === "wildcard" && ["companions", "playerbots"].includes(m.id) ? false : m.switchable}));
|
||||||
if(cmd==="module_set_enabled"){ modState[args.module]=args.enabled; return null; }
|
if(cmd==="module_set_enabled"){ modState[args.module]=args.enabled; return null; }
|
||||||
if(cmd==="all_settings") return [{key:"Visibility.Distance.Continents",value:"100",default:"90",doc:"Description: Visibility distance on continents. Range: 45-250 Default: 90",changed:true},{key:"Death.SicknessLevel",value:"11",default:"11",doc:"Description: Character level up to which characters get resurrection sickness. Default: 11",changed:false},{key:"Rate.Drop.Item.Epic",value:"3",default:"1",doc:"Description: Drop rates for money and items based on quality. Default: 1",changed:true},{key:"Instance.ResetTimeHour",value:"4",default:"4",doc:"Description: Hour of the day when the global instance reset occurs. Range: 0-23 Default: 4 - (04:00 AM)",changed:false},{key:"PlayerLimit",value:"1000",default:"1000",doc:"Description: Maximum number of players in the world.",changed:false}];
|
if(cmd==="all_settings") return [{key:"Visibility.Distance.Continents",value:"100",default:"90",doc:"Description: Visibility distance on continents. Range: 45-250 Default: 90",changed:true},{key:"Death.SicknessLevel",value:"11",default:"11",doc:"Description: Character level up to which characters get resurrection sickness. Default: 11",changed:false},{key:"Rate.Drop.Item.Epic",value:"3",default:"1",doc:"Description: Drop rates for money and items based on quality. Default: 1",changed:true},{key:"Instance.ResetTimeHour",value:"4",default:"4",doc:"Description: Hour of the day when the global instance reset occurs. Range: 0-23 Default: 4 - (04:00 AM)",changed:false},{key:"PlayerLimit",value:"1000",default:"1000",doc:"Description: Maximum number of players in the world.",changed:false}];
|
||||||
if(cmd==="all_settings_save") return Object.keys(args&&args.changes||{});
|
if(cmd==="all_settings_save") return Object.keys(args&&args.changes||{});
|
||||||
|
|||||||
@@ -697,6 +697,18 @@ const de: Partial<Record<keyof typeof en, string>> = {
|
|||||||
"rep.copied": "In die Zwischenablage kopiert.",
|
"rep.copied": "In die Zwischenablage kopiert.",
|
||||||
|
|
||||||
// Realmlist switcher and modules page
|
// Realmlist switcher and modules page
|
||||||
|
"realm.profileLabel": "Realm",
|
||||||
|
"realm.switching": "Realm wird gewechselt…",
|
||||||
|
"realm.create": "Wildcard-Realm erstellen",
|
||||||
|
"realm.restart": "Wechseln und neu starten",
|
||||||
|
"realm.select": "Realm auswählen",
|
||||||
|
"realm.active": "Aktiv: {name}",
|
||||||
|
"realm.separate": "Es läuft jeweils eine Welt. Jeder Realm behält eigene Charaktere, Fortschritte und Moduleinstellungen.",
|
||||||
|
"realm.modules": "CoA-Begleiter sind hier nicht verfügbar. Content Scaling und andere Module sind auf Wildcard experimentell.",
|
||||||
|
"realm.update": "Aktualisiere den Server auf eine Version mit Wildcard-Unterstützung, um diesen Realm zu erstellen.",
|
||||||
|
"realm.recovery": "Ein Realm-Wechsel wurde unterbrochen. Wähle einen Realm, um die gespeicherte Konfiguration wiederherzustellen.",
|
||||||
|
"realm.unsupported": "Auf Wildcard nicht verfügbar: Die Klassenmechanik wurde noch nicht geprüft.",
|
||||||
|
"realm.experimental": "Experimentell auf Wildcard · Spielkompatibilität noch nicht geprüft.",
|
||||||
"realm.label": "Realm",
|
"realm.label": "Realm",
|
||||||
"realm.other": "Anderer",
|
"realm.other": "Anderer",
|
||||||
"realm.add": "Realmlist hinzufügen …",
|
"realm.add": "Realmlist hinzufügen …",
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
/** English is the source of truth: every key must exist here; other locales fall back to it. */
|
/** English is the source of truth: every key must exist here; other locales fall back to it. */
|
||||||
const en = {
|
const en = {
|
||||||
|
"realm.profileLabel": "Realm",
|
||||||
|
"realm.switching": "Switching realm…",
|
||||||
|
"realm.create": "Create Wildcard realm",
|
||||||
|
"realm.restart": "Switch and restart",
|
||||||
|
"realm.select": "Select realm",
|
||||||
|
"realm.active": "Active: {name}",
|
||||||
|
"realm.separate": "One world runs at a time. Each realm keeps its own characters, progress and module settings.",
|
||||||
|
"realm.modules": "CoA companions are unavailable here. Content Scaling and other modules remain experimental on Wildcard.",
|
||||||
|
"realm.update": "Update the server to a build with Wildcard support to create this realm.",
|
||||||
|
"realm.recovery": "A realm switch was interrupted. Select a realm to recover the saved configuration.",
|
||||||
|
"realm.unsupported": "Unavailable on Wildcard: class mechanics have not been validated.",
|
||||||
|
"realm.experimental": "Experimental on Wildcard · gameplay compatibility is not yet verified.",
|
||||||
"app.name": "CoA Server Manager",
|
"app.name": "CoA Server Manager",
|
||||||
|
|
||||||
"nav.overview": "Overview",
|
"nav.overview": "Overview",
|
||||||
|
|||||||
@@ -697,6 +697,18 @@ const es: Partial<Record<keyof typeof en, string>> = {
|
|||||||
"rep.copied": "Copiado al portapapeles.",
|
"rep.copied": "Copiado al portapapeles.",
|
||||||
|
|
||||||
// Realmlist switcher and modules page
|
// Realmlist switcher and modules page
|
||||||
|
"realm.profileLabel": "Reino",
|
||||||
|
"realm.switching": "Cambiando de reino…",
|
||||||
|
"realm.create": "Crear reino Wildcard",
|
||||||
|
"realm.restart": "Cambiar y reiniciar",
|
||||||
|
"realm.select": "Seleccionar reino",
|
||||||
|
"realm.active": "Activo: {name}",
|
||||||
|
"realm.separate": "Solo funciona un mundo a la vez. Cada reino conserva sus personajes, progreso y ajustes de módulos.",
|
||||||
|
"realm.modules": "Los compañeros de CoA no están disponibles aquí. Content Scaling y los demás módulos siguen siendo experimentales en Wildcard.",
|
||||||
|
"realm.update": "Actualiza el servidor a una versión compatible con Wildcard para crear este reino.",
|
||||||
|
"realm.recovery": "Se interrumpió un cambio de reino. Selecciona un reino para recuperar la configuración guardada.",
|
||||||
|
"realm.unsupported": "No disponible en Wildcard: las mecánicas de clase aún no se han validado.",
|
||||||
|
"realm.experimental": "Experimental en Wildcard · compatibilidad en juego sin verificar.",
|
||||||
"realm.label": "Reino",
|
"realm.label": "Reino",
|
||||||
"realm.other": "Otro",
|
"realm.other": "Otro",
|
||||||
"realm.add": "Añadir una realmlist…",
|
"realm.add": "Añadir una realmlist…",
|
||||||
|
|||||||
@@ -697,6 +697,18 @@ const fr: Partial<Record<keyof typeof en, string>> = {
|
|||||||
"rep.copied": "Copié dans le presse-papiers.",
|
"rep.copied": "Copié dans le presse-papiers.",
|
||||||
|
|
||||||
// Realmlist switcher and modules page
|
// Realmlist switcher and modules page
|
||||||
|
"realm.profileLabel": "Royaume",
|
||||||
|
"realm.switching": "Changement de royaume…",
|
||||||
|
"realm.create": "Créer le royaume Wildcard",
|
||||||
|
"realm.restart": "Changer et redémarrer",
|
||||||
|
"realm.select": "Choisir le royaume",
|
||||||
|
"realm.active": "Actif : {name}",
|
||||||
|
"realm.separate": "Un seul monde fonctionne à la fois. Chaque royaume conserve ses personnages, sa progression et les paramètres de ses modules.",
|
||||||
|
"realm.modules": "Les compagnons CoA sont indisponibles ici. Content Scaling et les autres modules restent expérimentaux sur Wildcard.",
|
||||||
|
"realm.update": "Mettez le serveur à jour vers une version compatible avec Wildcard pour créer ce royaume.",
|
||||||
|
"realm.recovery": "Un changement de royaume a été interrompu. Choisissez un royaume pour restaurer la configuration enregistrée.",
|
||||||
|
"realm.unsupported": "Indisponible sur Wildcard : les mécaniques de classe ne sont pas encore validées.",
|
||||||
|
"realm.experimental": "Expérimental sur Wildcard · compatibilité en jeu non vérifiée.",
|
||||||
"realm.label": "Royaume",
|
"realm.label": "Royaume",
|
||||||
"realm.other": "Autre",
|
"realm.other": "Autre",
|
||||||
"realm.add": "Ajouter une realmlist…",
|
"realm.add": "Ajouter une realmlist…",
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ import type en from "./en";
|
|||||||
|
|
||||||
/** Русский. Черновой перевод; требует проверки носителем перед релизом. */
|
/** Русский. Черновой перевод; требует проверки носителем перед релизом. */
|
||||||
const ru: Partial<Record<keyof typeof en, string>> = {
|
const ru: Partial<Record<keyof typeof en, string>> = {
|
||||||
|
"realm.profileLabel": "Реалм",
|
||||||
|
"realm.switching": "Переключение реалма…",
|
||||||
|
"realm.create": "Создать реалм Wildcard",
|
||||||
|
"realm.restart": "Переключить и перезапустить",
|
||||||
|
"realm.select": "Выбрать реалм",
|
||||||
|
"realm.active": "Активен: {name}",
|
||||||
|
"realm.separate": "Работает один мир. У каждого реалма свои персонажи, прогресс и настройки модулей.",
|
||||||
|
"realm.modules": "CoA-боты здесь недоступны. Скалирование и другие модули пока экспериментальны для Wildcard.",
|
||||||
|
"realm.update": "Для создания реалма обновите сервер до сборки с поддержкой Wildcard.",
|
||||||
|
"realm.recovery": "Переключение прервалось. Выберите реалм, чтобы восстановить сохранённые настройки.",
|
||||||
|
"realm.unsupported": "Недоступен в Wildcard: классовые механики ещё не проверены.",
|
||||||
|
"realm.experimental": "Экспериментально в Wildcard · игровая совместимость пока не проверена.",
|
||||||
"nav.overview": "Обзор",
|
"nav.overview": "Обзор",
|
||||||
"nav.bots": "Боты",
|
"nav.bots": "Боты",
|
||||||
"nav.server": "Сервер",
|
"nav.server": "Сервер",
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ export interface StatusView {
|
|||||||
path_exists: boolean;
|
path_exists: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RealmMode = "coa" | "wildcard";
|
||||||
|
export interface RealmProfiles { active: RealmMode; wildcard_created: boolean; supported: boolean; recovery_pending: boolean }
|
||||||
|
|
||||||
export interface Human {
|
export interface Human {
|
||||||
code: string;
|
code: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -149,6 +152,7 @@ export interface SaveReport {
|
|||||||
export type BackupKind = "quick" | "full" | "config" | "database";
|
export type BackupKind = "quick" | "full" | "config" | "database";
|
||||||
|
|
||||||
export interface RecoveryPoint {
|
export interface RecoveryPoint {
|
||||||
|
realm?: RealmMode;
|
||||||
schema: number;
|
schema: number;
|
||||||
id: string;
|
id: string;
|
||||||
kind: BackupKind;
|
kind: BackupKind;
|
||||||
@@ -193,6 +197,7 @@ export interface ModuleView {
|
|||||||
page: string | null;
|
page: string | null;
|
||||||
/** not shown on the Modules page */
|
/** not shown on the Modules page */
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
|
compatibility?: "compatible" | "experimental" | "unsupported";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModuleSetting {
|
export interface ModuleSetting {
|
||||||
@@ -361,6 +366,8 @@ export interface ConsoleLine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
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 }),
|
||||||
defaultInstallDir: () => invoke<string>("default_install_dir"),
|
defaultInstallDir: () => invoke<string>("default_install_dir"),
|
||||||
scan: (path: string) => invoke<ScanReport>("scan_server", { path }),
|
scan: (path: string) => invoke<ScanReport>("scan_server", { path }),
|
||||||
add: (path: string) => invoke<ServerSummary>("add_server", { path }),
|
add: (path: string) => invoke<ServerSummary>("add_server", { path }),
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ export function BackupsPage({ serverId }: { serverId: string }) {
|
|||||||
const human = useHuman();
|
const human = useHuman();
|
||||||
const w = (iso: string) => when(iso, t, locale);
|
const w = (iso: string) => when(iso, t, locale);
|
||||||
const trigger = (x: string) => (TRIGGER_KEYS.includes(x) ? t(`bk.trigger.${x}` as Key) : x);
|
const trigger = (x: string) => (TRIGGER_KEYS.includes(x) ? t(`bk.trigger.${x}` as Key) : x);
|
||||||
const dbLabel = (x: string) => (DB_KEYS.includes(x) ? t(`bk.db.${x}` as Key) : x);
|
const dbLabel = (x: string) => {
|
||||||
|
const [realm, kind] = x.split("-");
|
||||||
|
if (kind && DB_KEYS.includes(kind)) return `${realm === "coa" ? "CoA" : "Wildcard"} · ${t(`bk.db.${kind}` as Key)}`;
|
||||||
|
return DB_KEYS.includes(x) ? t(`bk.db.${x}` as Key) : x;
|
||||||
|
};
|
||||||
const [points, setPoints] = useState<RecoveryPoint[] | null>(null);
|
const [points, setPoints] = useState<RecoveryPoint[] | null>(null);
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const [step, setStep] = useState<string | null>(null);
|
const [step, setStep] = useState<string | null>(null);
|
||||||
@@ -174,7 +178,7 @@ export function BackupsPage({ serverId }: { serverId: string }) {
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{p.label && p.trigger === "manual" && p.label !== "e2e" ? p.label : trigger(p.trigger)}</p>
|
<p className="font-medium">{p.label && p.trigger === "manual" && p.label !== "e2e" ? p.label : trigger(p.trigger)}</p>
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
{w(p.created_at)} · {t(`bk.kind.${p.kind}` as Key)} · {size(p.components.reduce((a, c) => a + c.bytes, 0))}
|
{w(p.created_at)} · {p.realm === "wildcard" ? "Wildcard" : "CoA"} · {t(`bk.kind.${p.kind}` as Key)} · {size(p.components.reduce((a, c) => a + c.bytes, 0))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-1">
|
<div className="flex shrink-0 gap-1">
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ export function ModulesPage({ serverId, onChanged }: { serverId: string; onChang
|
|||||||
<StatusBadge status={m.status} />
|
<StatusBadge status={m.status} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1.5 line-clamp-2 text-sm text-muted">{m.description[locale] ?? m.description.en}</p>
|
<p className="mt-1.5 line-clamp-2 text-sm text-muted">{m.description[locale] ?? m.description.en}</p>
|
||||||
|
{m.compatibility === "unsupported" && <p className="mt-2 text-xs text-warn">{t("realm.unsupported")}</p>}
|
||||||
|
{m.compatibility === "experimental" && <p className="mt-2 text-xs text-warn">{t("realm.experimental")}</p>}
|
||||||
</div>
|
</div>
|
||||||
{m.installed && m.switchable && (
|
{m.installed && m.switchable && (
|
||||||
<button
|
<button
|
||||||
@@ -146,7 +148,7 @@ export function ModulesPage({ serverId, onChanged }: { serverId: string; onChang
|
|||||||
<span className={cn("absolute top-0.5 h-5 w-5 rounded-full bg-white transition-all", m.enabled ? "left-[22px]" : "left-0.5")} />
|
<span className={cn("absolute top-0.5 h-5 w-5 rounded-full bg-white transition-all", m.enabled ? "left-[22px]" : "left-0.5")} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{m.installed && !m.switchable && m.status !== "soon" && <span className="mt-1.5 shrink-0 text-xs text-muted">{t("mod.alwaysOn")}</span>}
|
{m.installed && !m.switchable && m.status !== "soon" && m.compatibility !== "unsupported" && <span className="mt-1.5 shrink-0 text-xs text-muted">{t("mod.alwaysOn")}</span>}
|
||||||
{!m.installed && m.status !== "soon" && <span className="mt-1.5 shrink-0 text-xs text-muted">{t("mod.notHere")}</span>}
|
{!m.installed && m.status !== "soon" && <span className="mt-1.5 shrink-0 text-xs text-muted">{t("mod.notHere")}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-auto flex items-center gap-2">
|
<div className="mt-auto flex items-center gap-2">
|
||||||
@@ -158,7 +160,7 @@ export function ModulesPage({ serverId, onChanged }: { serverId: string; onChang
|
|||||||
>
|
>
|
||||||
<GithubMark className="h-4 w-4" />
|
<GithubMark className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
{m.installed && m.status !== "soon" && (
|
{m.installed && m.status !== "soon" && m.compatibility !== "unsupported" && (
|
||||||
<Button size="sm" variant="ghost" aria-expanded={open === m.id} onClick={() => setOpen(open === m.id ? null : m.id)}>
|
<Button size="sm" variant="ghost" aria-expanded={open === m.id} onClick={() => setOpen(open === m.id ? null : m.id)}>
|
||||||
<Settings2 className="h-3.5 w-3.5" aria-hidden /> {t("mod.settings")}
|
<Settings2 className="h-3.5 w-3.5" aria-hidden /> {t("mod.settings")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { applyServerUpdate, useServerUpdate } from "@/lib/serverUpdate";
|
|||||||
import { checkClient, useClientStatus } from "@/lib/clientUpdate";
|
import { checkClient, useClientStatus } from "@/lib/clientUpdate";
|
||||||
import { ClientDialog, type ClientDialogMode } from "@/screens/ClientDialog";
|
import { ClientDialog, type ClientDialogMode } from "@/screens/ClientDialog";
|
||||||
import { RealmlistMenu } from "@/screens/RealmlistMenu";
|
import { RealmlistMenu } from "@/screens/RealmlistMenu";
|
||||||
|
import { RealmPicker } from "@/screens/RealmPicker";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
@@ -63,11 +64,12 @@ function Row({ label, s }: { label: string; s: ServiceStatus }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Overview({ server, companions = true, onForget, onOpenUpdates }: { server: ServerSummary; /** the companions module is on */ companions?: boolean; onForget: () => Promise<void>; onOpenUpdates: () => void }) {
|
export function Overview({ server, companions = true, onForget, onOpenUpdates, onRealmChanged }: { server: ServerSummary; /** the companions module is on */ companions?: boolean; onForget: () => Promise<void>; onOpenUpdates: () => void; onRealmChanged: () => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const human = useHuman();
|
const human = useHuman();
|
||||||
const [status, setStatus] = useState<StatusView | null>(null);
|
const [status, setStatus] = useState<StatusView | null>(null);
|
||||||
const [action, setAction] = useState<Action>(null);
|
const [action, setAction] = useState<Action>(null);
|
||||||
|
const [realmBusy, setRealmBusy] = useState(false);
|
||||||
const [failure, setFailure] = useState<Failure | null>(null);
|
const [failure, setFailure] = useState<Failure | null>(null);
|
||||||
const [showDetails, setShowDetails] = useState(false);
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
const [pop, setPop] = useState<Population | null>(null);
|
const [pop, setPop] = useState<Population | null>(null);
|
||||||
@@ -197,10 +199,10 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates }:
|
|||||||
const all = [mysql, auth, world];
|
const all = [mysql, auth, world];
|
||||||
const running = all.every((s) => s.state === "running");
|
const running = all.every((s) => s.state === "running");
|
||||||
const anyUp = all.some((s) => s.state !== "stopped");
|
const anyUp = all.some((s) => s.state !== "stopped");
|
||||||
const transitioning = action !== null || status.busy;
|
const transitioning = action !== null || status.busy || realmBusy;
|
||||||
const conflict = all.find((s) => s.conflict)?.conflict ?? null;
|
const conflict = all.find((s) => s.conflict)?.conflict ?? null;
|
||||||
|
|
||||||
const headline = transitioning
|
const headline = realmBusy ? t("realm.switching") : transitioning
|
||||||
? action === "stopping"
|
? action === "stopping"
|
||||||
? t("status.stopping")
|
? t("status.stopping")
|
||||||
: t("status.starting")
|
: t("status.starting")
|
||||||
@@ -215,6 +217,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates }:
|
|||||||
<div className="w-full max-w-4xl">
|
<div className="w-full max-w-4xl">
|
||||||
<h1 className="text-2xl font-semibold">{server.name}</h1>
|
<h1 className="text-2xl font-semibold">{server.name}</h1>
|
||||||
<p className="selectable mt-0.5 text-xs text-muted">{server.path}</p>
|
<p className="selectable mt-0.5 text-xs text-muted">{server.path}</p>
|
||||||
|
<RealmPicker serverId={server.id} running={anyUp} disabled={transitioning || upd.applying} onBusy={setRealmBusy} onChanged={() => { onRealmChanged(); void poll(); }} />
|
||||||
|
|
||||||
<Card className="mt-6 p-7">
|
<Card className="mt-6 p-7">
|
||||||
<div className="mb-1 text-xs font-medium uppercase tracking-[0.14em] text-muted">{t("overview.server")}</div>
|
<div className="mb-1 text-xs font-medium uppercase tracking-[0.14em] text-muted">{t("overview.server")}</div>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Layers, Loader2 } from "lucide-react";
|
||||||
|
import { api, asUiError, type RealmMode, type RealmProfiles } from "@/lib/api";
|
||||||
|
import { useT } from "@/i18n";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export function RealmPicker({ serverId, running, disabled, onChanged, onBusy }: {
|
||||||
|
serverId: string; running: boolean; disabled: boolean; onChanged: () => void; onBusy: (busy: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
const [view, setView] = useState<RealmProfiles | null>(null);
|
||||||
|
const [selected, setSelected] = useState<RealmMode>("coa");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
void api.realmProfiles(serverId).then(v => { if (alive) { setView(v); setSelected(v.active); } }).catch(e => { if (alive) setError(asUiError(e).technical); });
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [serverId]);
|
||||||
|
|
||||||
|
async function select() {
|
||||||
|
setBusy(true); onBusy(true); setError(null);
|
||||||
|
try { const next = await api.realmSelect(serverId, selected, running); setView(next); onChanged(); }
|
||||||
|
catch (e) {
|
||||||
|
setError(asUiError(e).technical);
|
||||||
|
// Selection may have committed even if the subsequent server restart failed.
|
||||||
|
try { setView(await api.realmProfiles(serverId)); onChanged(); } catch { /* keep the operation error */ }
|
||||||
|
}
|
||||||
|
finally { setBusy(false); onBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Card className="mt-5 p-5" aria-busy={busy}>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</select>
|
||||||
|
{view && (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") : running ? t("realm.restart") : t("realm.select")}
|
||||||
|
</Button>}
|
||||||
|
{view && <span className="ml-auto text-xs text-muted">{t("realm.active", { name: view.active === "coa" ? "CoA" : "Wildcard" })}</span>}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm text-muted">{t("realm.separate")}</p>
|
||||||
|
{selected === "wildcard" && <p className="mt-2 text-sm text-warn">{t("realm.modules")}</p>}
|
||||||
|
{view && !view.supported && <p className="mt-2 text-sm text-warn">{t("realm.update")}</p>}
|
||||||
|
{view?.recovery_pending && <p className="mt-2 text-sm text-warn">{t("realm.recovery")}</p>}
|
||||||
|
{error && <p className="mt-3 selectable text-sm text-bad" role="alert">{error}</p>}
|
||||||
|
</Card>;
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ export function Shell(props: {
|
|||||||
const reloadModules = useCallback(() => {
|
const reloadModules = useCallback(() => {
|
||||||
void api
|
void api
|
||||||
.modulesList(props.activeId)
|
.modulesList(props.activeId)
|
||||||
.then((list) => setHiddenPages(list.filter((m) => m.page && (!m.installed || (m.switchable && !m.enabled))).map((m) => m.page as string)))
|
.then((list) => setHiddenPages(list.filter((m) => m.page && (!m.installed || m.compatibility === "unsupported" || (m.switchable && !m.enabled))).map((m) => m.page as string)))
|
||||||
.catch(() => setHiddenPages([]));
|
.catch(() => setHiddenPages([]));
|
||||||
}, [props.activeId]);
|
}, [props.activeId]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,7 +122,7 @@ export function Shell(props: {
|
|||||||
|
|
||||||
<main className="h-full flex-1 overflow-y-auto px-10 py-8">
|
<main className="h-full flex-1 overflow-y-auto px-10 py-8">
|
||||||
{page === "overview" ? (
|
{page === "overview" ? (
|
||||||
<Overview key={server.id} server={server} companions={!hiddenPages.includes("bots")} onForget={() => props.onForget(server.id)} onOpenUpdates={() => setPage("settings")} />
|
<Overview key={server.id} server={server} companions={!hiddenPages.includes("bots")} onForget={() => props.onForget(server.id)} onOpenUpdates={() => setPage("settings")} onRealmChanged={reloadModules} />
|
||||||
) : page === "bots" || page === "server" ? (
|
) : page === "bots" || page === "server" ? (
|
||||||
<SettingsPage key={`${server.id}-${page}`} serverId={server.id} scope={page} title={t(current.label)} question={t(current.question)} />
|
<SettingsPage key={`${server.id}-${page}`} serverId={server.id} scope={page} title={t(current.label)} question={t(current.question)} />
|
||||||
) : page === "console" ? (
|
) : page === "console" ? (
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
param(
|
param(
|
||||||
[string]$Source = 'C:\games\CoA-Repack',
|
[string]$Source = 'C:\games\CoA-Repack',
|
||||||
[string]$Target = 'C:\games\coa-fixture',
|
[string]$Target = 'C:\games\coa-fixture',
|
||||||
[int]$PortBase = 13000
|
[int]$PortBase = 13000,
|
||||||
|
[string]$DatabaseArchive = ''
|
||||||
)
|
)
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
if ((Resolve-Path $Source).Path -eq $Target) { throw 'source and target are the same' }
|
if ((Resolve-Path $Source).Path -eq $Target) { throw 'source and target are the same' }
|
||||||
@@ -27,7 +28,8 @@ foreach ($d in 'bin', 'lib', 'share') { Copy-Tree "mysql\$d" }
|
|||||||
Copy-Item "$Source\RELEASE.json", "$Source\MANIFEST.json" $Target
|
Copy-Item "$Source\RELEASE.json", "$Source\MANIFEST.json" $Target
|
||||||
|
|
||||||
# Pristine packaged database (never the live data directory).
|
# Pristine packaged database (never the live data directory).
|
||||||
& 'C:\Program Files\7-Zip\7z.exe' x "$Source\mysql\data.7z" "-o$Target\mysql" -y | Out-Null
|
if (-not $DatabaseArchive) { $DatabaseArchive = "$Source\mysql\data.7z" }
|
||||||
|
& 'C:\Program Files\7-Zip\7z.exe' x $DatabaseArchive "-o$Target\mysql" -y | Out-Null
|
||||||
if (-not (Test-Path "$Target\mysql\data\acore_characters")) { throw 'database extraction failed' }
|
if (-not (Test-Path "$Target\mysql\data\acore_characters")) { throw 'database extraction failed' }
|
||||||
|
|
||||||
New-Item -ItemType Junction -Path "$Target\Data" -Target "$Source\Data" | Out-Null
|
New-Item -ItemType Junction -Path "$Target\Data" -Target "$Source\Data" | Out-Null
|
||||||
|
|||||||
+1
-1
@@ -7,6 +7,6 @@ export default defineConfig({
|
|||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
|
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
server: { port: 1420, strictPort: true },
|
server: { port: 1420, strictPort: true, watch: { ignored: ["**/target/**", "**/src-tauri/**"] } },
|
||||||
build: { target: "es2022", outDir: "dist" },
|
build: { target: "es2022", outDir: "dist" },
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user