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:
Corfirean authored and GitHub committed 2026-10-03 11:12:04 +05:00
1 parent 831685dbd7
commit 1bcef5f8c2
35 files changed
+872 -37

No files matched your search

Generated
+3 -3
View File
@@ -347,7 +347,7 @@ dependencies = [
[[package]]
name = "coa-core"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -375,7 +375,7 @@ dependencies = [
[[package]]
name = "coa-release"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"chrono",
"coa-core",
@@ -384,7 +384,7 @@ dependencies = [
[[package]]
name = "coa-server-manager"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"coa-core",
"serde",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/coa-core", "crates/coa-release", "src-tauri"]
[workspace.package]
version = "0.4.0"
version = "0.5.0"
edition = "2021"
license = "AGPL-3.0-only"
+6
View File
@@ -18,6 +18,12 @@ no console, no config files, no database tools. Tauri 2 + Rust + React.
into **UPDATE**. Updates are authenticated (Ed25519), back up first, keep files you changed and can be rolled back.
* **Backups.** Characters, accounts and settings on demand, automatically before every update; verify and restore
from the app.
* **CoA / Wildcard realms.** Choose the active realm on Overview. A server build with Wildcard support is required;
the first switch copies world data and creates an empty character database. Accounts are shared, characters and
module settings are separate, and only one world runs. Switching a running server saves characters and restarts it.
CoA companions are unavailable on Wildcard; other optional modules are experimental. Backups include both realms,
and database updates apply to both.
See [realm profiles and validation](docs/WILDCARD.md).
* **Companions (bots).** Fill the world with level-appropriate bots, see progress, stop spawning, take them offline,
despawn N to relieve the server, and choose how many log in at server start.
* **Realmlist switcher.** Keep named realmlists (for example *Solo* and *PTR*) and switch the game client between them from a
@@ -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.
+92
View File
@@ -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(())
}
+24 -4
View File
@@ -63,6 +63,8 @@ pub struct RecoveryPoint {
pub created_at: String,
pub manager_version: String,
pub components: Vec<Component>,
#[serde(default)]
pub realm: crate::realms::Mode,
}
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() {
out.push("RELEASE.json".into());
}
walk(root, &root.join("Settings/realm-profiles"), &mut out);
if root.join("Settings/realm-profile.json").is_file() { out.push("Settings/realm-profile.json".into()); }
out.sort();
out
}
@@ -191,16 +195,24 @@ pub fn create(root: &Path, meta: &Path, kind: Kind, trigger: Trigger, label: Opt
let build = || -> Result<Vec<Component>> {
let mut components = Vec::new();
for name in wanted_databases(kind) {
let schema = db::schema_of(name)?;
let realm = crate::realms::state(root)?;
let mut databases: Vec<(String, &str)> = wanted_databases(kind).iter()
.map(|name| Ok((name.to_string(), realm.active.schema(name)?))).collect::<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"));
let component = with_database(root, |db| {
let db = db.clone().for_realm(crate::realms::Mode::Coa);
let size = db.schema_bytes(schema)?;
fsx::require_space(&partial, size / 2 + 32 * 1024 * 1024)?;
let tables = db.tables(schema)?.len();
let file = format!("{name}.sql.zst");
let (bytes, sha) = db.dump_to(schema, &partial.join(&file))?;
Ok(Component { name: (*name).into(), path: file, bytes, sha256: Some(sha), tables: Some(tables), files: None })
Ok(Component { name, path: file, bytes, sha256: Some(sha), tables: Some(tables), files: None })
})?;
components.push(component);
}
@@ -219,6 +231,7 @@ pub fn create(root: &Path, meta: &Path, kind: Kind, trigger: Trigger, label: Opt
}
};
let point = RecoveryPoint {
realm: crate::realms::state(root)?.active,
schema: 1,
id: id.clone(),
kind,
@@ -289,6 +302,9 @@ pub fn delete(meta: &Path, id: &str) -> Result<()> {
/// Put configuration files back. Files the backup does not contain are left alone. A safety point is taken first.
pub fn restore_configs(root: &Path, meta: &Path, id: &str) -> Result<RecoveryPoint> {
let point = get(meta, id)?;
if point.realm != crate::realms::state(root)?.active {
return Err(Error::Invalid("Select the realm this backup belongs to before restoring it.".into()));
}
let comp = point.components.iter().find(|c| c.name == "configs").ok_or_else(|| Error::Invalid("this backup has no configuration".into()))?;
if !verify(meta, id)?.ok {
return Err(Error::Invalid("This backup is damaged and cannot be restored.".into()));
@@ -314,6 +330,9 @@ pub struct DbRestore {
/// import into a staging schema, sanity-check it, then swap tables atomically and keep the old schema.
pub fn restore_database(root: &Path, meta: &Path, id: &str, name: &str) -> Result<DbRestore> {
let point = get(meta, id)?;
if point.realm != crate::realms::state(root)?.active {
return Err(Error::Invalid("Select the realm this backup belongs to before restoring it.".into()));
}
let comp = point.components.iter().find(|c| c.name == name && c.sha256.is_some()).ok_or_else(|| Error::Invalid(format!("this backup has no {name} database")))?;
if !verify(meta, id)?.ok {
return Err(Error::Invalid("This backup is damaged and cannot be restored.".into()));
@@ -322,7 +341,7 @@ pub fn restore_database(root: &Path, meta: &Path, id: &str, name: &str) -> Resul
if now.world.state != ServiceState::Stopped || now.auth.state != ServiceState::Stopped {
return Err(Error::Invalid("Stop the server before restoring a database.".into()));
}
let live = db::schema_of(name)?;
let live = if name.contains('-') { db::schema_of(name)? } else { point.realm.schema(name)? };
let expected_tables = comp.tables.unwrap_or(0);
let dump = point_dir(meta, id)?.join(&comp.path);
let stamp = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string();
@@ -331,6 +350,7 @@ pub fn restore_database(root: &Path, meta: &Path, id: &str, name: &str) -> Resul
let safety = create(root, meta, Kind::Database, Trigger::BeforeRestore, Some(format!("before restoring {name} from {id}")), &|_| {})?;
with_database(root, |db| {
let db = db.clone().for_realm(crate::realms::Mode::Coa);
let staging = format!("{live}_restore_{stamp}");
let old = format!("{live}_before_restore_{stamp}");
if db.schema_exists(&staging)? || db.schema_exists(&old)? {
+1
View File
@@ -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.
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 schema = scope.schema();
let t = targets(root, scope)?;
+96 -2
View File
@@ -44,6 +44,7 @@ pub struct Db {
port: u16,
user: &'static str,
password: Secret,
realm: crate::realms::Mode,
}
#[derive(Deserialize)]
@@ -67,6 +68,8 @@ fn no_window(cmd: &mut Command) {
pub const SCHEMAS: [(&str, &str); 3] = [("characters", "acore_characters"), ("auth", "acore_auth"), ("world", "acore_world")];
pub fn schema_of(kind: &str) -> Result<&'static str> {
if let Some(kind) = kind.strip_prefix("wildcard-") { return crate::realms::Mode::Wildcard.schema(kind); }
if let Some(kind) = kind.strip_prefix("coa-") { return crate::realms::Mode::Coa.schema(kind); }
SCHEMAS.iter().find(|(k, _)| *k == kind).map(|(_, s)| *s).ok_or_else(|| Error::Invalid(format!("unknown database {kind}")))
}
@@ -87,7 +90,38 @@ impl Db {
Account::Admin => ("root", creds.root_password),
Account::App => ("acore", creds.app_password),
};
Ok(Db { bin, port: ports.mysql, user, password: Secret(password) })
Ok(Db { bin, port: ports.mysql, user, password: Secret(password), realm: crate::realms::state(root)?.active })
}
pub fn for_realm(mut self, realm: crate::realms::Mode) -> Self { self.realm = realm; self }
pub fn realm_schema<'a>(&self, name: &'a str) -> &'a str {
if self.realm == crate::realms::Mode::Wildcard {
match name {
"acore_world" => "acore_world_wildcard",
"acore_characters" => "acore_characters_wildcard",
_ => name,
}
} else { name }
}
pub fn clone_structure(&self, source: &str, dest: &str, cache: &Path) -> Result<()> {
if !ident_ok(source) || !ident_ok(dest) { return Err(Error::Invalid("Invalid schema name.".into())); }
let path = cache.join("characters-structure.sql");
self.dump_structure_to(source, &path)?;
self.run_sql_file(dest, &path)?;
std::fs::remove_file(path)?;
Ok(())
}
pub fn dump_structure_to(&self, source: &str, path: &Path) -> Result<()> {
if !ident_ok(source) { return Err(Error::Invalid("Invalid schema name.".into())); }
let mut c = self.command("mysqldump.exe");
c.args(["--no-data", "--no-tablespaces", "--skip-comments", "--default-character-set=utf8mb4", source]);
c.stdout(Stdio::from(File::create(&path)?));
let out = c.output()?;
if !out.status.success() { return Err(self.fail(&out.stderr)); }
Ok(())
}
fn command(&self, tool: &str) -> Command {
@@ -123,9 +157,10 @@ impl Db {
pub fn query(&self, sql: &str) -> Result<String> {
let mut c = self.command("mysql.exe");
c.args(["--batch", "--skip-column-names", "--connect-timeout=5"]);
c.args(["--default-character-set=utf8mb4", "--max-allowed-packet=128M"]);
c.stdin(Stdio::piped());
let mut child = c.spawn()?;
child.stdin.take().expect("piped").write_all(sql.as_bytes())?;
child.stdin.take().expect("piped").write_all(route_sql(sql, self.realm).as_bytes())?;
let out = child.wait_with_output()?;
if !out.status.success() {
return Err(self.fail(&out.stderr));
@@ -134,6 +169,7 @@ impl Db {
}
pub fn schema_exists(&self, name: &str) -> Result<bool> {
let name = self.realm_schema(name);
if !ident_ok(name) {
return Err(Error::Invalid("bad schema name".into()));
}
@@ -141,6 +177,7 @@ impl Db {
}
pub fn tables(&self, schema: &str) -> Result<Vec<String>> {
let schema = self.realm_schema(schema);
if !ident_ok(schema) {
return Err(Error::Invalid("bad schema name".into()));
}
@@ -150,6 +187,7 @@ impl Db {
/// Bytes of data + index the schema occupies (used to estimate backup size).
pub fn schema_bytes(&self, schema: &str) -> Result<u64> {
let schema = self.realm_schema(schema);
if !ident_ok(schema) {
return Err(Error::Invalid("bad schema name".into()));
}
@@ -159,6 +197,7 @@ impl Db {
/// Stored routines / triggers / views make a table-level swap unsafe; count them.
pub fn extra_objects(&self, schema: &str) -> Result<u64> {
let schema = self.realm_schema(schema);
if !ident_ok(schema) {
return Err(Error::Invalid("bad schema name".into()));
}
@@ -170,6 +209,7 @@ impl Db {
/// Consistent dump of one schema, zstd-compressed to `out`. Returns (compressed bytes, sha256 of the file).
pub fn dump_to(&self, schema: &str, out: &Path) -> Result<(u64, String)> {
let schema = self.realm_schema(schema);
if !ident_ok(schema) {
return Err(Error::Invalid("bad schema name".into()));
}
@@ -203,9 +243,15 @@ impl Db {
/// Run a plain SQL file against `schema`; the first error aborts and is returned.
pub fn run_sql_file(&self, schema: &str, file: &Path) -> Result<()> {
let schema = self.realm_schema(schema);
if !ident_ok(schema) {
return Err(Error::Invalid("bad schema name".into()));
}
if self.realm == crate::realms::Mode::Wildcard {
let sql = std::fs::read_to_string(file)?;
self.query(&format!("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; USE `{schema}`;\n{sql}"))?;
return Ok(());
}
let mut c = self.command("mysql.exe");
c.args(["--default-character-set=utf8mb4", "--init-command=SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci", "--max-allowed-packet=128M"]);
c.arg(schema);
@@ -219,6 +265,7 @@ impl Db {
/// Import a dump produced by `dump_to` into `schema` (which must already exist and be empty).
pub fn import_from(&self, schema: &str, dump: &Path) -> Result<()> {
let schema = self.realm_schema(schema);
if !ident_ok(schema) {
return Err(Error::Invalid("bad schema name".into()));
}
@@ -278,3 +325,50 @@ pub fn write_console_credentials(root: &Path, password: &str) -> Result<()> {
pub fn valid_identifier(name: &str) -> bool {
ident_ok(name)
}
// Route only SQL identifiers. User names, values, comments and longer identifiers remain untouched.
fn route_sql(sql: &str, realm: crate::realms::Mode) -> String {
if realm == crate::realms::Mode::Coa { return sql.into(); }
let bytes = sql.as_bytes();
let mut out = String::new();
let mut i = 0;
while i < bytes.len() {
let start = i;
if matches!(bytes[i], b'\'' | b'"') {
let quote = bytes[i]; i += 1;
while i < bytes.len() {
if bytes[i] == b'\\' { i = (i + 2).min(bytes.len()); }
else if bytes[i] == quote {
i += 1;
if i < bytes.len() && bytes[i] == quote { i += 1; } else { break; }
} else { i += 1; }
}
out.push_str(&sql[start..i]);
} else if bytes[i] == b'#' || sql[i..].starts_with("--") || sql[i..].starts_with("/*") {
if sql[i..].starts_with("/*") {
i = sql[i + 2..].find("*/").map(|p| i + p + 4).unwrap_or(bytes.len());
} else { i = sql[i..].find('\n').map(|p| i + p).unwrap_or(bytes.len()); }
out.push_str(&sql[start..i]);
} else if bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_' {
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { i += 1; }
out.push_str(match &sql[start..i] {
"acore_world" => "acore_world_wildcard",
"acore_characters" => "acore_characters_wildcard",
other => other,
});
} else {
let c = sql[i..].chars().next().unwrap(); out.push(c); i += c.len_utf8();
}
}
out
}
#[cfg(test)]
mod realm_tests {
use super::*;
#[test]
fn routes_schema_identifiers_without_rewriting_account_values() {
let sql = "SELECT * FROM `acore_characters`.characters WHERE name='acore_world' AND x=\"acore_characters\"; -- acore_world\nSELECT * FROM acore_world.items;";
assert_eq!(route_sql(sql, crate::realms::Mode::Wildcard), "SELECT * FROM `acore_characters_wildcard`.characters WHERE name='acore_world' AND x=\"acore_characters\"; -- acore_world\nSELECT * FROM acore_world_wildcard.items;");
}
}
+11
View File
@@ -80,6 +80,17 @@ fn launcher(root: &Path) -> Result<(PathBuf, PathBuf)> {
/// Run a launcher verb to completion (blocking; call from a worker thread).
pub fn run(root: &Path, verb: Verb) -> Result<DriverOutcome> {
let root = fsx::canonicalize_lenient(root)?;
if verb == Verb::StartAll {
crate::realms::before_start(&root)?;
if root.join("Settings/realm-profile.json").exists() {
let db = crate::db::Db::from_repack(&root, crate::db::Account::Admin)?;
if !db.ping() {
let out = run(&root, Verb::StartMysql)?;
if !out.ok { return Ok(out); }
}
crate::realms::setup_realmlist(&root)?;
}
}
let (python, script) = launcher(&root)?;
let mut cmd = Command::new(&python);
cmd.arg("-B").arg(&script).arg(verb.arg()).current_dir(&root).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
+2 -1
View File
@@ -117,7 +117,8 @@ pub fn apply_realm_address(root: &Path, host: &str) -> Result<()> {
return Err(Error::Invalid("That address is not valid.".into()));
}
let db = Db::from_repack(root, Account::Admin)?;
db.query(&format!("UPDATE acore_auth.realmlist SET address='{host}', localAddress='{host}' WHERE id=1;"))?;
let realm = crate::realms::state(root)?.active.realm_id();
db.query(&format!("UPDATE acore_auth.realmlist SET address='{host}', localAddress='{host}' WHERE id={realm};"))?;
Ok(())
}
+1
View File
@@ -29,6 +29,7 @@ pub mod ra;
pub mod allsettings;
pub mod modules;
pub mod realmlist;
pub mod realms;
pub mod registry;
pub mod release;
pub mod update;
+8 -2
View File
@@ -97,6 +97,7 @@ pub struct ModuleView {
pub icon: String,
pub page: Option<String>,
pub hidden: bool,
pub compatibility: String,
}
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> {
let wildcard = crate::realms::state(root).map(|s| s.active == crate::realms::Mode::Wildcard).unwrap_or(true);
cat.iter()
.cloned()
.map(|e| {
@@ -126,14 +128,16 @@ fn list_in(cat: &[Entry], root: &Path) -> Vec<ModuleView> {
// the active file decides; without one the documented default is what the server will use
let enabled = !e.switchable
|| read(&conf).or_else(|| read(&dist)).and_then(|c| c.get(&e.enable_key).map(truthy)).unwrap_or(e.default_on);
let blocked = wildcard && matches!(e.id.as_str(), "companions" | "playerbots");
ModuleView {
compatibility: if blocked { "unsupported" } else if wildcard && e.id != "client-compat" { "experimental" } else { "compatible" }.into(),
id: e.id,
name: e.name,
description: e.description,
repo: e.repo,
installed,
enabled,
switchable: e.switchable,
enabled: enabled && !blocked,
switchable: e.switchable && !blocked,
status: e.status,
icon: e.icon,
page: e.page,
@@ -164,6 +168,7 @@ fn backup(meta: &Path, conf: &Path) -> Result<()> {
}
pub fn set_enabled(root: &Path, meta: &Path, id: &str, on: bool) -> Result<()> {
if on { crate::realms::guard_module(root, id)?; }
set_enabled_in(&catalog(), root, meta, id, on)
}
@@ -217,6 +222,7 @@ fn settings_in(cat: &[Entry], root: &Path, id: &str) -> Result<Vec<Setting>> {
/// 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>> {
crate::realms::guard_module(root, id)?;
save_settings_in(&catalog(), root, meta, id, changes)
}
+371
View File
@@ -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());
}
}
+18 -3
View File
@@ -476,9 +476,24 @@ impl Env for RepackEnv<'_> {
fn migrate(&self, manifest: &Manifest, staged: &Path) -> Result<ApplyReport> {
let (root, meta) = (self.root, self.meta_dir);
crate::backup::with_database(root, |db| {
crate::migrations::apply_pending(db, &manifest.migrations, staged, &|| {
Ok(crate::backup::create(root, meta, crate::backup::Kind::Database, crate::backup::Trigger::BeforeMigration, None, &|_| {})?.id)
})
let realms = crate::realms::state(root)?;
let mut result = crate::migrations::apply_pending(db, &manifest.migrations, staged, &|| {
Ok(crate::backup::create(root, meta, crate::backup::Kind::Full, crate::backup::Trigger::BeforeMigration, None, &|_| {})?.id)
})?;
if realms.wildcard_created && result.failed.is_none() {
let other = if realms.active == crate::realms::Mode::Coa { crate::realms::Mode::Wildcard } else { crate::realms::Mode::Coa };
let other_db = db.clone().for_realm(other);
let shared_snapshot = result.snapshot.clone();
let migrations: Vec<_> = manifest.migrations.iter().filter(|m| m.db != "auth").cloned().collect();
let extra = crate::migrations::apply_pending(&other_db, &migrations, staged, &|| {
if let Some(id) = &shared_snapshot { return Ok(id.clone()); }
Ok(crate::backup::create(root, meta, crate::backup::Kind::Full, crate::backup::Trigger::BeforeMigration, None, &|_| {})?.id)
})?;
result.applied.extend(extra.applied);
result.failed = extra.failed;
if result.snapshot.is_none() { result.snapshot = extra.snapshot; }
}
Ok(result)
})
}
+42
View File
@@ -0,0 +1,42 @@
# Realm profiles
Overview offers CoA and Wildcard. The installed worldserver must contain Wildcard support and the database must
already have its Wildcard migrations. The Manager refuses an unsupported binary, an occupied realm ID 2 or an
existing destination database rather than overwrite them.
The first switch copies the CoA world into `acore_world_wildcard` and creates `acore_characters_wildcard` from
the character schema and neutral server seed tables. Existing characters, card collections and progress are not
copied. Accounts remain in shared `acore_auth`. Realm IDs 1 and 2 share the world port; only the selected world
runs, and the inactive realm is listed offline. A switch while running stops the server gracefully and restarts it.
Configuration profiles are saved under `Settings/realm-profiles`. An interrupted switch can restore its previous
configuration from the journal. CoA companion bots cannot be enabled on Wildcard. Content Scaling and the other
optional modules retain their settings but show experimental compatibility; class mechanics need separate gameplay
validation. Switching back restores the CoA module settings.
Quick/database backups include both character databases and shared auth. Full backups also include both worlds.
Recovery points identify their active realm; select that realm before restoring them. Server migrations apply to
both realms, with auth applied once. The server updater can replace its launcher: the Manager reapplies the realm
adapter before the next start.
![Wildcard overview](screenshots/wildcard-demo/overview.jpg)
![Module compatibility](screenshots/wildcard-demo/modules.jpg)
## Validation for v0.5.0
- `cargo test --workspace`: 178 core tests passed; command layer and examples compiled.
- `npm run build`, translation key checks and changelog validation passed.
- Native disposable fixture on offset ports: fresh Wildcard creation and startup, CoA startup after switching back,
separate probe values and restored CoA configuration. A second fresh creation verified the arena-season seed fix.
- Full backup verified both worlds, both character databases, shared auth and configurations.
- Native migration probe applied once to each realm and once to auth; repeating it did nothing.
- Restoring Wildcard characters removed a fixture-only change and preserved the CoA probe value.
- Core gameplay wrapper: `VERIFY ALL: PASSED`, gameplay stage only, real clock. First login (7 assertions),
level rolls/reroll (31), skill card rolls (13), stat path (12). The level-roll and skill-card scenarios passed on isolated
reruns after failing in the shared batch; the wrapper records two batch-sensitive cases.
- Browser demo verified active profile, hidden Bots navigation and module compatibility labels. Screenshots show
mock IPC; native checks above exercised the actual backend and server.
The gameplay harness required fixture-only synchronization of already applied migration history by verified SQL
hashes and its normal Windows module config path. Owned server files and databases were not modified.
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

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