mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 11:51:03 +00:00
Fix settings input, client launch, server repair and simultaneous realms
This commit is contained in:
1 parent
162bfd1e04
commit
c02c635463
48 files changed
+1218
-72
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Server multipliers now accept decimals typed with a dot or comma
|
||||
---
|
||||
Talent settings now explain that the multiplier does not affect CoA talents. The rested experience setting now correctly describes bonus accumulation.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Play launches only the game client without starting your local server
|
||||
---
|
||||
Use the left button to start the server and PLAY to launch the client with your selected realmlist, including connections to other servers.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: added
|
||||
audience: admins
|
||||
title: Check database structure and repair server files with a safety backup
|
||||
---
|
||||
Settings can check both realm databases and restore official files plus pending SQL. Installation and updates check character-save columns. Clean packages no longer keep old character migration records when rebuilding their database.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: added
|
||||
audience: admins
|
||||
title: Start CoA and Wildcard together from Settings
|
||||
---
|
||||
Both worlds can run on separate ports with their own characters, settings and logs, sharing accounts. Stop saves both worlds before shutting down the shared database.
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Probe only a disposable fixture: enable both worlds, verify isolation, gracefully stop both.
|
||||
use coa_core::{Error, Result, driver::{self, Verb}, realms::{self, Mode}, multiworld, process::{self, ServiceState}};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let root = PathBuf::from(std::env::args().nth(1).ok_or_else(|| Error::Invalid("fixture path required".into()))?);
|
||||
if !root.file_name().is_some_and(|n| n.to_string_lossy().contains("fixture")) { return Err(Error::Invalid("Refusing a non-fixture server.".into())); }
|
||||
let action = std::env::args().nth(2).unwrap_or_else(|| "smoke".into());
|
||||
if action == "stop" { driver::run(&root, Verb::StopAll)?; return Ok(()); }
|
||||
driver::run(&root, Verb::StopAll)?;
|
||||
if action == "wildcard" { realms::select(&root, Mode::Wildcard)?; }
|
||||
multiworld::set_enabled(&root, true)?;
|
||||
let result = (|| {
|
||||
let out = driver::run(&root, Verb::StartAll)?;
|
||||
if !out.ok { return Err(Error::Invalid(out.output)); }
|
||||
let first = process::observe(&root, &coa_core::layout::read_ports(&root));
|
||||
let second_root = multiworld::root(&root);
|
||||
let second = process::observe(&second_root, &coa_core::layout::read_ports(&second_root));
|
||||
assert_eq!(first.world.state, ServiceState::Running);
|
||||
assert_eq!(second.world.state, ServiceState::Running);
|
||||
assert_ne!(first.world.pid, second.world.pid);
|
||||
assert_ne!(first.world.port, second.world.port);
|
||||
assert!(second.mysql.pid.is_none() && second.auth.pid.is_none());
|
||||
let db = coa_core::db::Db::from_repack(&root, coa_core::db::Account::Admin)?;
|
||||
let realms = db.query("SELECT id,port,flag FROM acore_auth.realmlist WHERE id IN (1,2) ORDER BY id;")?;
|
||||
println!("Two distinct world PIDs; shared auth/MySQL; realm rows:\n{realms}");
|
||||
for mode in [Mode::Coa, Mode::Wildcard] {
|
||||
let problems = coa_core::schema_check::check(&db.clone().for_realm(mode), &root)?;
|
||||
println!("{}: {} database problems", mode.name(), problems.len());
|
||||
for p in problems.iter().take(5) { println!("{}.{}.{}: {}", p.database, p.table, p.column, p.detail); }
|
||||
}
|
||||
assert!(multiworld::set_enabled(&root, false).is_err());
|
||||
assert!(realms::select(&root, Mode::Wildcard).is_err());
|
||||
Ok(())
|
||||
})();
|
||||
let stopped = driver::run(&root, Verb::StopAll)?;
|
||||
if !stopped.ok { return Err(Error::Invalid(stopped.output)); }
|
||||
assert!(!multiworld::is_running(&root));
|
||||
assert_eq!(process::observe(&root, &coa_core::layout::read_ports(&root)).mysql.state, ServiceState::Stopped);
|
||||
result?;
|
||||
println!("Both worlds shut down before the shared database.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Signed-package repair smoke test. Only a disposable fixture may be used.
|
||||
use std::path::PathBuf;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use coa_core::{Result, Error, fsx, package::{self, BuildOptions}, manifest::{Kind, Migration}, registry::{InstallKind, InstallMeta, MetaDir, metadata_dir_for}, pkgsource::Source, realms::Mode};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let root = PathBuf::from(std::env::args().nth(1).ok_or_else(|| Error::Invalid("fixture path required".into()))?);
|
||||
if !root.file_name().is_some_and(|n| n.to_string_lossy().contains("fixture")) { return Err(Error::Invalid("Refusing a non-fixture server.".into())); }
|
||||
let work = tempfile::tempdir()?;
|
||||
let base_tree = work.path().join("base-tree");
|
||||
let update_tree = work.path().join("update-tree");
|
||||
let base_pkg = work.path().join("base-pkg");
|
||||
let update_pkg = work.path().join("update-pkg");
|
||||
let probe = "Core/reference/repair-e2e.txt";
|
||||
fsx::atomic_write(&base_tree.join(probe), b"base")?;
|
||||
fsx::atomic_write(&update_tree.join(probe), b"correct official file")?;
|
||||
let sql = "CREATE TABLE IF NOT EXISTS acore_world.manager_repair_probe (id INT PRIMARY KEY); INSERT IGNORE INTO acore_world.manager_repair_probe VALUES (42);";
|
||||
let id = "20261004_repair_e2e";
|
||||
fsx::atomic_write(&update_tree.join(format!("_migrations/world/{id}.sql")), sql.as_bytes())?;
|
||||
let options = |kind, version: &str, migrations| BuildOptions { kind, version: version.into(), core_commit: None, built_at: "fixture".into(), part_size: 1 << 20, bots_commit: None, migrations };
|
||||
let base = package::build(&base_tree, &base_pkg, &options(Kind::Base, "1.0.0", vec![]), &|_| {})?;
|
||||
let update = package::build(&update_tree, &update_pkg, &options(Kind::Update, "1.0.1", vec![Migration { id: id.into(), db: "world".into(), sha256: fsx::sha256_bytes(sql.as_bytes()), destructive: false }]), &|_| {})?;
|
||||
// A test-only trust key, unrelated to production signing material.
|
||||
let key = SigningKey::from_bytes(&[23; 32]);
|
||||
let public = STANDARD.encode(key.verifying_key().as_bytes());
|
||||
for pkg in [&base_pkg, &update_pkg] {
|
||||
fsx::atomic_write(&pkg.join("manifest.json.sig"), STANDARD.encode(key.sign(&std::fs::read(pkg.join("manifest.json"))?).to_bytes()).as_bytes())?;
|
||||
}
|
||||
let dir = metadata_dir_for(&root)?;
|
||||
let mut meta = InstallMeta::new(InstallKind::New, &root);
|
||||
meta.core.version = Some(update.version.clone());
|
||||
MetaDir::create(&root, &meta)?;
|
||||
fsx::atomic_write_json(&dir.join("manifests/base.json"), &base)?;
|
||||
fsx::atomic_write_json(&dir.join("manifests/update-1.0.1.json"), &update)?;
|
||||
fsx::atomic_write(&root.join(probe), b"corrupted")?;
|
||||
let settings = std::fs::read(root.join("Settings/repack.json"))?;
|
||||
let counts = coa_core::backup::with_database(&root, |db| db.query("SELECT COUNT(*) FROM acore_auth.account; SELECT COUNT(*) FROM acore_characters.characters; SELECT COUNT(*) FROM acore_characters_wildcard.characters;"))?;
|
||||
let report = coa_core::repair::run(&root, &dir, &Source::Dir(base_pkg), &Source::Dir(update_pkg), &public, &|s,p| println!("{p}% {s}"))?;
|
||||
assert_eq!(std::fs::read(root.join(probe))?, b"correct official file");
|
||||
assert!(report.error.is_none(), "{:?}", report.error);
|
||||
assert_eq!(report.applied.len(), 2, "Migration must run in both worlds");
|
||||
assert!(report.database.iter().all(|d| d.problems.is_empty() && d.migrations.iter().all(|m| m.status == coa_core::migrations::Status::Applied)));
|
||||
assert_eq!(std::fs::read(root.join("Settings/repack.json"))?, settings);
|
||||
assert!(coa_core::backup::verify(&dir, &report.backup)?.ok);
|
||||
coa_core::backup::with_database(&root, |db| {
|
||||
assert_eq!(db.query("SELECT COUNT(*) FROM acore_auth.account; SELECT COUNT(*) FROM acore_characters.characters; SELECT COUNT(*) FROM acore_characters_wildcard.characters;")?, counts);
|
||||
for mode in [Mode::Coa, Mode::Wildcard] {
|
||||
assert_eq!(db.clone().for_realm(mode).query("SELECT id FROM acore_world.manager_repair_probe;")?, "42");
|
||||
}
|
||||
// Corrupt only the second realm, proving that checks read its actual schema.
|
||||
let wildcard = db.clone().for_realm(Mode::Wildcard);
|
||||
wildcard.query("ALTER TABLE acore_characters.character_action DROP COLUMN type;")?;
|
||||
let problems = coa_core::schema_check::check(&wildcard, &root)?;
|
||||
assert!(problems.iter().any(|p| p.table == "character_action" && p.column == "type"));
|
||||
assert!(coa_core::schema_check::check(&db.clone().for_realm(Mode::Coa), &root)?.is_empty());
|
||||
wildcard.query("ALTER TABLE acore_characters.character_action ADD COLUMN type TINYINT UNSIGNED NOT NULL DEFAULT 0;")?;
|
||||
Ok(())
|
||||
})?;
|
||||
println!("Repair verified: signed file restored, both realms migrated, backup valid, accounts/characters/settings preserved, missing column isolated to its realm.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Check full schema contracts and blocking custom columns, only against a disposable fixture.
|
||||
use coa_core::{Result, Error, backup, schema_check, realms::Mode};
|
||||
use std::path::PathBuf;
|
||||
fn main() -> Result<()> {
|
||||
let root = PathBuf::from(std::env::args().nth(1).ok_or_else(|| Error::Invalid("fixture path required".into()))?);
|
||||
if !root.file_name().is_some_and(|n| n.to_string_lossy().contains("fixture")) { return Err(Error::Invalid("Refusing a non-fixture server.".into())); }
|
||||
backup::with_database(&root, |db| {
|
||||
let coa = db.clone().for_realm(Mode::Coa);
|
||||
let wildcard = db.clone().for_realm(Mode::Wildcard);
|
||||
assert!(schema_check::check(&coa, &root)?.is_empty());
|
||||
assert!(schema_check::check(&wildcard, &root)?.is_empty());
|
||||
let reference = tempfile::tempdir()?;
|
||||
schema_check::capture(&coa, reference.path())?;
|
||||
assert!(schema_check::check(&coa, reference.path())?.is_empty());
|
||||
wildcard.query("ALTER TABLE acore_characters.character_action ADD COLUMN manager_required INT NOT NULL;")?;
|
||||
let result = (|| {
|
||||
assert!(schema_check::check(&wildcard, &root)?.iter().any(|p| p.table == "character_action" && p.column == "manager_required"));
|
||||
assert!(schema_check::check(&coa, &root)?.is_empty());
|
||||
Ok(())
|
||||
})();
|
||||
wildcard.query("ALTER TABLE acore_characters.character_action DROP COLUMN manager_required;")?;
|
||||
result
|
||||
})?;
|
||||
println!("Full schema contract and a blocking NOT NULL column verified; second-realm corruption does not affect CoA checks.");
|
||||
Ok(())
|
||||
}
|
||||
@@ -162,7 +162,7 @@ pub fn with_database<T>(root: &Path, f: impl FnOnce(&Db) -> Result<T>) -> Result
|
||||
let result = f(&db);
|
||||
if !mysql_was_up {
|
||||
let now = process::observe(root, &read_ports(root));
|
||||
if now.world.state == ServiceState::Stopped && now.auth.state == ServiceState::Stopped {
|
||||
if now.world.state == ServiceState::Stopped && now.auth.state == ServiceState::Stopped && !crate::multiworld::is_running(root) {
|
||||
let _ = driver::run(root, Verb::StopAll);
|
||||
}
|
||||
}
|
||||
@@ -338,7 +338,7 @@ pub fn restore_database(root: &Path, meta: &Path, id: &str, name: &str) -> Resul
|
||||
return Err(Error::Invalid("This backup is damaged and cannot be restored.".into()));
|
||||
}
|
||||
let now = process::observe(root, &read_ports(root));
|
||||
if now.world.state != ServiceState::Stopped || now.auth.state != ServiceState::Stopped {
|
||||
if now.world.state != ServiceState::Stopped || now.auth.state != ServiceState::Stopped || crate::multiworld::is_running(root) {
|
||||
return Err(Error::Invalid("Stop the server before restoring a database.".into()));
|
||||
}
|
||||
let live = if name.contains('-') { db::schema_of(name)? } else { point.realm.schema(name)? };
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"character_inventory": [
|
||||
"guid",
|
||||
"bag",
|
||||
"slot",
|
||||
"item"
|
||||
],
|
||||
"characters": [
|
||||
"guid",
|
||||
"account",
|
||||
"name",
|
||||
"race",
|
||||
"class",
|
||||
"gender",
|
||||
"level",
|
||||
"xp",
|
||||
"money",
|
||||
"skin",
|
||||
"face",
|
||||
"hairStyle",
|
||||
"hairColor",
|
||||
"facialStyle",
|
||||
"bankSlots",
|
||||
"restState",
|
||||
"playerFlags",
|
||||
"map",
|
||||
"instance_id",
|
||||
"instance_mode_mask",
|
||||
"position_x",
|
||||
"position_y",
|
||||
"position_z",
|
||||
"orientation",
|
||||
"trans_x",
|
||||
"trans_y",
|
||||
"trans_z",
|
||||
"trans_o",
|
||||
"transguid",
|
||||
"taximask",
|
||||
"cinematic",
|
||||
"totaltime",
|
||||
"leveltime",
|
||||
"rest_bonus",
|
||||
"logout_time",
|
||||
"is_logout_resting",
|
||||
"resettalents_cost",
|
||||
"resettalents_time",
|
||||
"extra_flags",
|
||||
"stable_slots",
|
||||
"at_login",
|
||||
"zone",
|
||||
"death_expire_time",
|
||||
"taxi_path",
|
||||
"arenaPoints",
|
||||
"totalHonorPoints",
|
||||
"todayHonorPoints",
|
||||
"yesterdayHonorPoints",
|
||||
"totalKills",
|
||||
"todayKills",
|
||||
"yesterdayKills",
|
||||
"chosenTitle",
|
||||
"knownCurrencies",
|
||||
"watchedFaction",
|
||||
"drunk",
|
||||
"health",
|
||||
"power1",
|
||||
"power2",
|
||||
"power3",
|
||||
"power4",
|
||||
"power5",
|
||||
"power6",
|
||||
"power7",
|
||||
"latency",
|
||||
"talentGroupsCount",
|
||||
"activeTalentGroup",
|
||||
"exploredZones",
|
||||
"equipmentCache",
|
||||
"ammoId",
|
||||
"knownTitles",
|
||||
"actionBars",
|
||||
"grantableLevels",
|
||||
"innTriggerId",
|
||||
"extraBonusTalentCount"
|
||||
],
|
||||
"character_spell": [
|
||||
"guid",
|
||||
"spell",
|
||||
"specMask"
|
||||
],
|
||||
"character_skills": [
|
||||
"guid",
|
||||
"skill",
|
||||
"value",
|
||||
"max"
|
||||
],
|
||||
"character_homebind": [
|
||||
"guid",
|
||||
"mapId",
|
||||
"zoneId",
|
||||
"posX",
|
||||
"posY",
|
||||
"posZ"
|
||||
],
|
||||
"character_reputation": [
|
||||
"guid",
|
||||
"faction",
|
||||
"standing",
|
||||
"flags"
|
||||
],
|
||||
"character_action": [
|
||||
"guid",
|
||||
"spec",
|
||||
"button",
|
||||
"action",
|
||||
"type"
|
||||
],
|
||||
"character_talent": [
|
||||
"guid",
|
||||
"spell",
|
||||
"specMask"
|
||||
],
|
||||
"item_instance": [
|
||||
"itemEntry",
|
||||
"owner_guid",
|
||||
"creatorGuid",
|
||||
"giftCreatorGuid",
|
||||
"count",
|
||||
"duration",
|
||||
"charges",
|
||||
"flags",
|
||||
"enchantments",
|
||||
"randomPropertyId",
|
||||
"durability",
|
||||
"playedTime",
|
||||
"text",
|
||||
"guid"
|
||||
],
|
||||
"character_stats": [
|
||||
"guid",
|
||||
"maxhealth",
|
||||
"maxpower1",
|
||||
"maxpower2",
|
||||
"maxpower3",
|
||||
"maxpower4",
|
||||
"maxpower5",
|
||||
"maxpower6",
|
||||
"maxpower7",
|
||||
"strength",
|
||||
"agility",
|
||||
"stamina",
|
||||
"intellect",
|
||||
"spirit",
|
||||
"armor",
|
||||
"resHoly",
|
||||
"resFire",
|
||||
"resNature",
|
||||
"resFrost",
|
||||
"resShadow",
|
||||
"resArcane",
|
||||
"blockPct",
|
||||
"dodgePct",
|
||||
"parryPct",
|
||||
"critPct",
|
||||
"rangedCritPct",
|
||||
"spellCritPct",
|
||||
"attackPower",
|
||||
"rangedAttackPower",
|
||||
"spellPower",
|
||||
"resilience"
|
||||
]
|
||||
}
|
||||
@@ -218,12 +218,17 @@ pub fn build(p: &Params, say: &dyn Fn(&str)) -> Result<()> {
|
||||
db.query(&sql)?;
|
||||
// 2. characters: a brand-new schema from the core's base scripts
|
||||
db.query("DROP DATABASE IF EXISTS `acore_characters`; CREATE DATABASE `acore_characters` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;")?;
|
||||
// The ledger lives in world, so dropping characters does not clear its migration history.
|
||||
// Never carry that history into a freshly rebuilt character database.
|
||||
if db.tables("acore_world")?.iter().any(|t| t == "coa_manager_migrations") {
|
||||
db.query("DELETE FROM acore_world.coa_manager_migrations WHERE `db`='characters';")?;
|
||||
}
|
||||
for f in &base_chars {
|
||||
db.run_sql_file("acore_characters", f)?;
|
||||
}
|
||||
// 3. every migration up to the core commit (already-recorded ones are skipped)
|
||||
let before = status(db, &migrations)?;
|
||||
let pending: Vec<_> = migrations.iter().zip(&before).filter(|(_, i)| i.status == Status::Pending).map(|(m, _)| m.clone()).collect();
|
||||
let pending: Vec<_> = migrations.iter().zip(&before).filter(|(_, i)| i.status != Status::Applied).map(|(m, _)| m.clone()).collect();
|
||||
say(&format!("Applying {} database updates", pending.len()));
|
||||
let report = apply_pending(db, &pending, &staging, &|| Ok("(scratch database)".into()))?;
|
||||
if let Some((id, why)) = report.failed {
|
||||
@@ -251,6 +256,7 @@ pub fn build(p: &Params, say: &dyn Fn(&str)) -> Result<()> {
|
||||
if accounts != "0" || chars != "0" || templates != 21 {
|
||||
return Err(Error::Invalid(format!("The database is not clean ({accounts} accounts, {chars} player characters, {templates} templates).")));
|
||||
}
|
||||
crate::schema_check::capture(db, out)?;
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ impl Db {
|
||||
}
|
||||
|
||||
pub fn for_realm(mut self, realm: crate::realms::Mode) -> Self { self.realm = realm; self }
|
||||
pub fn realm(&self) -> crate::realms::Mode { self.realm }
|
||||
|
||||
pub fn realm_schema<'a>(&self, name: &'a str) -> &'a str {
|
||||
if self.realm == crate::realms::Mode::Wildcard {
|
||||
|
||||
@@ -64,7 +64,9 @@ pub fn run(root: &Path, meta: &InstallMeta) -> Report {
|
||||
|
||||
let ports = layout::read_ports(root);
|
||||
let o = process::observe(root, &ports);
|
||||
for (title, s) in [("Database", &o.mysql), ("Login server", &o.auth), ("World server", &o.world)] {
|
||||
let mut services = vec![("Database", &o.mysql), ("Login server", &o.auth), ("World server", &o.world)];
|
||||
if let Some(second) = &o.secondary_world { services.push(("Second world", second)); }
|
||||
for (title, s) in services {
|
||||
let (lvl, text) = match (s.state, &s.conflict) {
|
||||
(ServiceState::Running, _) => (Level::Ok, "Running".to_string()),
|
||||
(_, Some(conf)) => (Level::Fail, format!("Port {} is used by another program (process {}).", conf.port, conf.pid)),
|
||||
@@ -124,6 +126,8 @@ pub struct FileProblem {
|
||||
pub fn verify_managed(root: &Path, meta: &InstallMeta) -> Vec<FileProblem> {
|
||||
let mut out = Vec::new();
|
||||
for (rel, want) in &meta.original_hashes {
|
||||
let lower = rel.replace('\\', "/").to_lowercase();
|
||||
if lower.starts_with("mysql/data/") || lower.starts_with("settings/") || lower.starts_with("core/configs/") && !lower.ends_with(".dist") { continue; }
|
||||
let Ok(p) = fsx::safe_join(root, rel) else { continue };
|
||||
match fsx::sha256_file(&p) {
|
||||
Err(_) => out.push(FileProblem { path: rel.clone(), kind: "missing" }),
|
||||
@@ -241,6 +245,9 @@ pub fn export_package(root: &Path, meta_dir: &Path, manager_log: &Path, meta: &I
|
||||
if let Ok(b) = fs::read(root.join("RELEASE.json")) {
|
||||
add("RELEASE.json", redact(&String::from_utf8_lossy(&b)).as_bytes())?;
|
||||
}
|
||||
if let Ok(b) = fs::read(meta_dir.join("logs/database-checks.json")) {
|
||||
add("database-checks.json", redact(&String::from_utf8_lossy(&b)).as_bytes())?;
|
||||
}
|
||||
zip.finish().map_err(|e| Error::Invalid(e.to_string()))?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::fsx;
|
||||
pub enum Verb {
|
||||
StartAll,
|
||||
StartMysql,
|
||||
StartWorld,
|
||||
StopAll,
|
||||
}
|
||||
|
||||
@@ -25,13 +26,14 @@ impl Verb {
|
||||
match self {
|
||||
Verb::StartAll => "start-all",
|
||||
Verb::StartMysql => "start-mysql",
|
||||
Verb::StartWorld => "start-world",
|
||||
Verb::StopAll => "stop-all",
|
||||
}
|
||||
}
|
||||
|
||||
fn timeout(self) -> Duration {
|
||||
match self {
|
||||
Verb::StartAll => Duration::from_secs(420),
|
||||
Verb::StartAll | Verb::StartWorld => Duration::from_secs(420),
|
||||
Verb::StartMysql => Duration::from_secs(120),
|
||||
Verb::StopAll => Duration::from_secs(240),
|
||||
}
|
||||
@@ -83,6 +85,7 @@ pub fn run(root: &Path, verb: Verb) -> Result<DriverOutcome> {
|
||||
if crate::docker::is_docker(&root) {
|
||||
return crate::docker::run(&root, verb);
|
||||
}
|
||||
if verb == Verb::StopAll { crate::multiworld::stop(&root)?; }
|
||||
if verb == Verb::StartAll {
|
||||
crate::realms::before_start(&root)?;
|
||||
if root.join("Settings/realm-profile.json").exists() {
|
||||
@@ -146,6 +149,7 @@ pub fn run(root: &Path, verb: Verb) -> Result<DriverOutcome> {
|
||||
}
|
||||
};
|
||||
tracing::info!(?verb, ok, ?code, "driver: launcher verb finished");
|
||||
if ok && verb == Verb::StartAll { crate::multiworld::start(&root)?; }
|
||||
Ok(DriverOutcome { ok, exit_code, code, human: code.map(ErrorCode::human), output })
|
||||
}
|
||||
|
||||
|
||||
@@ -78,13 +78,21 @@ fn run_elevated(commands: &[String]) -> Result<()> {
|
||||
|
||||
/// Make sure both rules exist (adds only the missing ones) and confirm afterwards.
|
||||
pub fn ensure_rules(ports: &Ports) -> Result<Status> {
|
||||
ensure_rules_with_secondary(ports, None)
|
||||
}
|
||||
|
||||
pub fn ensure_rules_with_secondary(ports: &Ports, secondary: Option<u16>) -> Result<Status> {
|
||||
let have = status();
|
||||
let cmds = plan(ports, &have);
|
||||
let mut cmds = plan(ports, &have);
|
||||
let second_name = secondary.map(|port| format!("CoA Server Manager - Second World {port}"));
|
||||
if let (Some(port), Some(name)) = (secondary, &second_name) {
|
||||
if !rule_exists(name) { cmds.push(add_cmd(name, port)); }
|
||||
}
|
||||
if !cmds.is_empty() {
|
||||
run_elevated(&cmds)?;
|
||||
}
|
||||
let now = status();
|
||||
if now.auth && now.world {
|
||||
if now.auth && now.world && second_name.as_ref().is_none_or(|name| rule_exists(name)) {
|
||||
Ok(now)
|
||||
} else {
|
||||
Err(Error::Invalid("The firewall rules could not be confirmed after the change.".into()))
|
||||
|
||||
@@ -118,7 +118,8 @@ pub fn apply_realm_address(root: &Path, host: &str) -> Result<()> {
|
||||
}
|
||||
let db = Db::from_repack(root, Account::Admin)?;
|
||||
let realm = crate::realms::state(root)?.active.realm_id();
|
||||
db.query(&format!("UPDATE acore_auth.realmlist SET address='{host}', localAddress='{host}' WHERE id={realm};"))?;
|
||||
let where_realms = if crate::realms::state(root)?.simultaneous { "id IN (1,2)".into() } else { format!("id={realm}") };
|
||||
db.query(&format!("UPDATE acore_auth.realmlist SET address='{host}', localAddress='{host}' WHERE {where_realms};"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -261,6 +261,13 @@ pub fn install_base(p: &Params, report: &dyn Fn(Step)) -> Result<Installed> {
|
||||
package::extract(&parts_dir, &m, &staging_root, &|done, total| say("Unpacking", 50 + (done * 30 / total.max(1)) as u8, None))?;
|
||||
say("Preparing database", 82, None);
|
||||
bootstrap_database(&staging_root)?;
|
||||
crate::backup::with_database(&staging_root, |db| {
|
||||
let problems = crate::schema_check::check(db, &staging_root)?;
|
||||
if let Some(p) = problems.first() {
|
||||
return Err(Error::Invalid(format!("The packaged database is incomplete: {}.{}.{}: {}.", p.database, p.table, p.column, p.detail)));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
fs::remove_file(staging_root.join(MARKER))?;
|
||||
|
||||
// 4. Commit: the only moment the destination changes.
|
||||
|
||||
@@ -24,6 +24,9 @@ pub mod population;
|
||||
pub mod process;
|
||||
pub mod manifest;
|
||||
pub mod migrations;
|
||||
pub mod multiworld;
|
||||
pub mod repair;
|
||||
pub mod schema_check;
|
||||
pub mod net;
|
||||
pub mod package;
|
||||
pub mod ra;
|
||||
|
||||
@@ -123,19 +123,29 @@ pub fn status(store: &dyn Store, list: &[Migration]) -> Result<Vec<Item>> {
|
||||
Ok(list
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let row = rows.iter().find(|r| r.db == m.db && r.id == m.id);
|
||||
// Manager records are appended after the core's updates table and are authoritative.
|
||||
let row = recorded(&rows, m);
|
||||
let changed = row.is_some_and(|r| hash_changed(r, m));
|
||||
Item {
|
||||
id: m.id.clone(),
|
||||
db: m.db.clone(),
|
||||
status: row.map(|r| r.status).unwrap_or(Status::Pending),
|
||||
status: if changed { Status::Failed } else { row.map(|r| r.status).unwrap_or(Status::Pending) },
|
||||
destructive: m.destructive,
|
||||
baseline: row.map(|r| r.baseline).unwrap_or(false),
|
||||
error: row.and_then(|r| r.error.clone()),
|
||||
error: if changed { Some("An already applied SQL file changed. Publish a new migration instead of replaying it.".into()) } else { row.and_then(|r| r.error.clone()) },
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn recorded<'a>(rows: &'a [LedgerRow], m: &Migration) -> Option<&'a LedgerRow> {
|
||||
rows.iter().rev().find(|r| r.db == m.db && r.id == m.id)
|
||||
}
|
||||
|
||||
fn hash_changed(row: &LedgerRow, m: &Migration) -> bool {
|
||||
row.status == Status::Applied && row.sha256 != "0".repeat(64) && !row.sha256.eq_ignore_ascii_case(&m.sha256)
|
||||
}
|
||||
|
||||
/// Record migrations as already present without running them (adopting an existing server whose database
|
||||
/// was brought up to date by hand).
|
||||
pub fn baseline(store: &dyn Store, list: &[Migration]) -> Result<usize> {
|
||||
@@ -163,7 +173,12 @@ pub struct ApplyReport {
|
||||
pub fn apply_pending(store: &dyn Store, list: &[Migration], dir: &Path, snapshot: &dyn Fn() -> Result<String>) -> Result<ApplyReport> {
|
||||
let rows = store.load()?;
|
||||
// The list order is the release's order of application (released updates, then pending, then modules).
|
||||
let todo: Vec<&Migration> = list.iter().filter(|m| !rows.iter().any(|r| r.db == m.db && r.id == m.id && r.status == Status::Applied)).collect();
|
||||
for m in list {
|
||||
if recorded(&rows, m).is_some_and(|r| hash_changed(r, m)) {
|
||||
return Err(Error::Invalid(format!("Applied database update {} ({}) has a different checksum. A new migration is required; it was not replayed.", m.id, m.db)));
|
||||
}
|
||||
}
|
||||
let todo: Vec<&Migration> = list.iter().filter(|m| recorded(&rows, m).is_none_or(|r| r.status != Status::Applied)).collect();
|
||||
|
||||
// Resolve and verify every file before running anything.
|
||||
let mut files = Vec::new();
|
||||
@@ -254,6 +269,27 @@ mod tests {
|
||||
panic!("no snapshot expected")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_failure_overrides_an_old_core_update_record() {
|
||||
let (d, list) = setup(&[("characters", "same_id", "SELECT 1;")]);
|
||||
let store = Mem::default();
|
||||
let old = LedgerRow { db: "characters".into(), id: "same_id".into(), sha256: "0".repeat(64), status: Status::Applied, error: None, baseline: true };
|
||||
let failed = LedgerRow { sha256: list[0].sha256.clone(), status: Status::Failed, error: Some("previous failure".into()), baseline: false, ..old.clone() };
|
||||
store.rows.borrow_mut().extend([old, failed]);
|
||||
assert_eq!(status(&store, &list).unwrap()[0].status, Status::Failed);
|
||||
assert_eq!(apply_pending(&store, &list, d.path(), &no_snapshot).unwrap().applied, ["same_id"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_applied_sql_is_reported_and_never_replayed() {
|
||||
let (d, list) = setup(&[("characters", "same_id", "SELECT 1;")]);
|
||||
let store = Mem::default();
|
||||
store.rows.borrow_mut().push(LedgerRow { db: "characters".into(), id: "same_id".into(), sha256: "a".repeat(64), status: Status::Applied, error: None, baseline: false });
|
||||
assert_eq!(status(&store, &list).unwrap()[0].status, Status::Failed);
|
||||
assert!(apply_pending(&store, &list, d.path(), &no_snapshot).is_err());
|
||||
assert!(store.ran.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_in_order_once_and_records_the_ledger() {
|
||||
let (d, list) = setup(&[("world", "2026_09_01_00_a", "CREATE TABLE t (id INT);"), ("world", "2026_09_02_00_b", "ALTER TABLE t ADD c INT;")]);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! A second supervised world with isolated configs, logs and ports; auth and MySQL remain shared.
|
||||
use std::{collections::BTreeMap, fs, path::{Path, PathBuf}};
|
||||
use crate::{Error, Result, fsx, realms::{self, Mode, RealmState}, driver::{self, Verb}, process::{self, ServiceState}};
|
||||
|
||||
pub fn root(main: &Path) -> PathBuf { main.join(".realms/secondary") }
|
||||
|
||||
pub fn is_running(main: &Path) -> bool {
|
||||
let second = root(main);
|
||||
if !second.exists() { return false; }
|
||||
let status = process::observe(&second, &crate::layout::read_ports(&second));
|
||||
status.world.state != ServiceState::Stopped
|
||||
|| ["supervisor", "relay"].iter().any(|n| process::read_state_record(&second, n).is_some_and(|p| process::is_alive(&p)))
|
||||
}
|
||||
|
||||
fn copy_tree(from: &Path, to: &Path) -> Result<()> {
|
||||
fs::create_dir_all(to)?;
|
||||
for entry in fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
if entry.file_name() == "Logs" || entry.file_name() == "reports" || entry.file_name() == "__pycache__" { continue; }
|
||||
if entry.file_type()?.is_symlink() { return Err(Error::Invalid("A linked runtime file cannot be used for a second realm.".into())); }
|
||||
let target = fsx::ensure_within(to, &to.join(entry.file_name()))?;
|
||||
if entry.file_type()?.is_dir() { copy_tree(&entry.path(), &target)?; }
|
||||
else if !target.is_file() || fsx::sha256_file(&entry.path())? != fsx::sha256_file(&target)? { fs::copy(entry.path(), target)?; }
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_enabled(main: &Path, enabled: bool) -> Result<realms::View> {
|
||||
if enabled && !cfg!(windows) { return Err(Error::Invalid("Simultaneous realms currently require Windows.".into())); }
|
||||
if enabled {
|
||||
if !realms::view(main)?.supported { return Err(Error::Invalid("Update the server to a build with Wildcard support first.".into())); }
|
||||
secondary_launcher(&fs::read_to_string(main.join("Scripts/manage.py"))?)?;
|
||||
}
|
||||
let status = process::observe(main, &crate::layout::read_ports(main));
|
||||
if status.world.state != ServiceState::Stopped || status.auth.state != ServiceState::Stopped || is_running(main) {
|
||||
return Err(Error::Invalid("Stop both realms before changing simultaneous startup.".into()));
|
||||
}
|
||||
let original = realms::state(main)?.active;
|
||||
if enabled && !realms::state(main)?.wildcard_created {
|
||||
realms::select(main, Mode::Wildcard)?;
|
||||
if original != Mode::Wildcard { realms::select(main, original)?; }
|
||||
}
|
||||
let mut s = realms::state(main)?;
|
||||
if enabled {
|
||||
let p = crate::layout::read_ports(main);
|
||||
let mut reserved = vec![p.mysql, p.auth, p.world, p.ra];
|
||||
let mut choose = |saved: Option<u16>, near: u16| -> Result<u16> {
|
||||
if let Some(port) = saved.filter(|p| *p > 0 && !reserved.contains(p)) { reserved.push(port); return Ok(port); }
|
||||
let port = (near.saturating_add(1)..=u16::MAX).find(|p| !reserved.contains(p) && std::net::TcpListener::bind(("127.0.0.1", *p)).is_ok())
|
||||
.ok_or_else(|| Error::Invalid("No free port for the second realm.".into()))?;
|
||||
reserved.push(port); Ok(port)
|
||||
};
|
||||
s.secondary_world_port = Some(choose(s.secondary_world_port, p.world)?);
|
||||
s.secondary_ra_port = Some(choose(s.secondary_ra_port, p.ra)?);
|
||||
}
|
||||
s.simultaneous = enabled;
|
||||
fsx::atomic_write_json(&main.join("Settings/realm-profile.json"), &s)?;
|
||||
realms::view(main)
|
||||
}
|
||||
|
||||
fn prepare(main: &Path) -> Result<PathBuf> {
|
||||
if is_running(main) { return Err(Error::Invalid("The second realm is still running; stop it before preparing its files.".into())); }
|
||||
let s = realms::state(main)?;
|
||||
let mode = if s.active == Mode::Coa { Mode::Wildcard } else { Mode::Coa };
|
||||
let second = fsx::ensure_within(main, &root(main))?;
|
||||
for folder in ["Runtime", "BugReport", "mysql/bin"] { copy_tree(&main.join(folder), &fsx::ensure_within(&second, &second.join(folder))?)?; }
|
||||
if main.join("Core/reference").is_dir() { copy_tree(&main.join("Core/reference"), &fsx::ensure_within(&second, &second.join("Core/reference"))?)?; }
|
||||
for entry in fs::read_dir(main.join("Core"))? {
|
||||
let entry = entry?;
|
||||
if entry.file_type()?.is_file() && (entry.file_name() == "worldserver.exe" || entry.path().extension().is_some_and(|x| x == "dll")) {
|
||||
fs::create_dir_all(second.join("Core"))?;
|
||||
fs::copy(entry.path(), fsx::ensure_within(&second, &second.join("Core").join(entry.file_name()))?)?;
|
||||
}
|
||||
}
|
||||
let files: BTreeMap<String, Vec<u8>> = fsx::read_json(&main.join(format!("Settings/realm-profiles/{}.json", mode.name())))?;
|
||||
// A previous secondary run may have used the other realm's module files.
|
||||
for path in crate::backup::config_files(&second) {
|
||||
if path.starts_with("Core/configs/") && path.ends_with(".conf") && !files.contains_key(&path) {
|
||||
fs::remove_file(fsx::ensure_within(&second, &fsx::safe_join(&second, &path)?)?)?;
|
||||
}
|
||||
}
|
||||
for (path, bytes) in files {
|
||||
if !(path.starts_with("Core/configs/") && path.ends_with(".conf") || path.starts_with("Settings/") && path.ends_with(".template")) {
|
||||
return Err(Error::Invalid("Invalid secondary realm configuration path.".into()));
|
||||
}
|
||||
let text = String::from_utf8(bytes).map_err(|_| Error::Invalid("Invalid realm config encoding.".into()))?;
|
||||
let shared = main.to_string_lossy().replace('\\', "/");
|
||||
let text = text.replace("@DATA@", &format!("{shared}/Data"))
|
||||
.replace("@ASCENSION_DBC@", &format!("{shared}/Data/dbc/Ascension"))
|
||||
.replace("@MYSQL_EXE@", &format!("{shared}/mysql/bin/mysql.exe"));
|
||||
let text = if path == "Settings/worldserver.conf.template" {
|
||||
let main_conf = crate::config::parser::ConfFile::parse_bytes(&fs::read(main.join(&path))?)?;
|
||||
let mut other_conf = crate::config::parser::ConfFile::parse_bytes(text.as_bytes())?;
|
||||
if let Some(bind) = main_conf.get("BindIP") { other_conf.set("BindIP", bind, &[]); }
|
||||
other_conf.set("Ra.IP", "\"127.0.0.1\"", &[]);
|
||||
other_conf.to_text()
|
||||
} else if path == "Core/configs/modules/coa.conf" {
|
||||
let mut conf = crate::config::parser::ConfFile::parse_bytes(text.as_bytes())?;
|
||||
if crate::friends::bind_is_open(main) { conf.set("CoA.AllowRemoteClients", "1", &[]); }
|
||||
conf.to_text()
|
||||
} else { text };
|
||||
fsx::atomic_write(&fsx::ensure_within(&second, &fsx::safe_join(&second, &path)?)?, text.as_bytes())?;
|
||||
}
|
||||
let mut cfg: serde_json::Value = fsx::read_json(&main.join("Settings/repack.json"))?;
|
||||
cfg["worldPort"] = s.secondary_world_port.ok_or_else(|| Error::Invalid("Second realm world port is missing.".into()))?.into();
|
||||
cfg["raPort"] = s.secondary_ra_port.ok_or_else(|| Error::Invalid("Second realm console port is missing.".into()))?.into();
|
||||
fsx::atomic_write_json(&second.join("Settings/repack.json"), &cfg)?;
|
||||
fsx::atomic_write(&second.join("Settings/database.json"), &fs::read(main.join("Settings/database.json"))?)?;
|
||||
fsx::atomic_write_json(&second.join("Settings/realm-profile.json"), &RealmState { active: mode, wildcard_created: true, ..Default::default() })?;
|
||||
let source = fs::read_to_string(main.join("Scripts/manage.py"))?;
|
||||
let source = secondary_launcher(&source)?;
|
||||
fsx::atomic_write(&second.join("Scripts/manage.py"), source.as_bytes())?;
|
||||
Ok(second)
|
||||
}
|
||||
|
||||
fn secondary_launcher(source: &str) -> Result<String> {
|
||||
let marker = "def start_world(config, offset):\n start_mysql(config)";
|
||||
let source = source.replace("\r\n", "\n");
|
||||
if !source.contains(marker) { return Err(Error::Invalid("The launcher does not support a shared database world. Update the server first.".into())); }
|
||||
Ok(source.replace(marker, "def start_world(config, offset):\n mysql(admin=\"ping\")"))
|
||||
}
|
||||
|
||||
pub fn start(main: &Path) -> Result<()> {
|
||||
if !realms::state(main)?.simultaneous { return Ok(()); }
|
||||
if is_running(main) {
|
||||
let second = root(main);
|
||||
if process::observe(&second, &crate::layout::read_ports(&second)).world.state == ServiceState::Running { return Ok(()); }
|
||||
return Err(Error::Invalid("The second realm is still starting or stopping.".into()));
|
||||
}
|
||||
let second = prepare(main)?;
|
||||
let out = driver::run(&second, Verb::StartWorld)?;
|
||||
if !out.ok { return Err(Error::Invalid(format!("The second realm did not start: {}", out.output))); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn stop(main: &Path) -> Result<()> {
|
||||
let second = root(main);
|
||||
if !second.join("Scripts/manage.py").exists() {
|
||||
if is_running(main) { return Err(Error::Invalid("The running second realm's launcher is missing. The shared database was left running.".into())); }
|
||||
return Ok(());
|
||||
}
|
||||
// Its state has no auth/MySQL records: the shared database remains up until the primary world stops.
|
||||
if is_running(main) {
|
||||
let out = driver::run(&second, Verb::StopAll)?;
|
||||
if !out.ok || is_running(main) { return Err(Error::Invalid(format!("The second realm could not stop; the database was left running: {}", out.output))); }
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn secondary_never_starts_the_shared_database() {
|
||||
let patched = secondary_launcher("def start_world(config, offset):\r\n start_mysql(config)\r\n wait_ready('world', 8086)\r\n").unwrap();
|
||||
assert!(patched.contains("mysql(admin=\"ping\")"));
|
||||
assert!(!patched.contains("start_mysql(config)"));
|
||||
assert!(secondary_launcher("def start_world(): pass").is_err());
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ pub const BOOTSTRAP_CREDENTIALS: &str = "Settings/database.bootstrap.json";
|
||||
|
||||
/// Files that never belong in a shipped package: per-install state, logs, secrets, old binaries.
|
||||
pub fn excluded(rel: &str) -> bool {
|
||||
if rel.replace('\\', "/").to_lowercase().starts_with(".realms/") { return true; }
|
||||
let l = rel.to_lowercase();
|
||||
let name = l.rsplit('/').next().unwrap_or("");
|
||||
l.starts_with(".state/")
|
||||
|
||||
@@ -61,6 +61,7 @@ pub struct Observed {
|
||||
pub mysql: ServiceStatus,
|
||||
pub auth: ServiceStatus,
|
||||
pub world: ServiceStatus,
|
||||
pub secondary_world: Option<ServiceStatus>,
|
||||
}
|
||||
|
||||
pub fn read_state_record(root: &Path, name: &str) -> Option<ProcessIdentity> {
|
||||
@@ -298,10 +299,15 @@ pub fn observe(root: &Path, ports: &Ports) -> Observed {
|
||||
}
|
||||
let listen = listeners();
|
||||
let (world, auth, mysql) = crate::layout::executables(root);
|
||||
let secondary = crate::multiworld::root(root);
|
||||
let secondary_world = if crate::realms::state(root).is_ok_and(|s| s.simultaneous) || crate::multiworld::is_running(root) {
|
||||
Some(service(&secondary, "world-secondary", secondary.join("Core/worldserver.exe"), crate::layout::read_ports(&secondary).world, &listen))
|
||||
} else { None };
|
||||
Observed {
|
||||
mysql: service(root, "mysql", mysql, ports.mysql, &listen),
|
||||
auth: service(root, "auth", auth, ports.auth, &listen),
|
||||
world: service(root, "world", world, ports.world, &listen),
|
||||
secondary_world,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,12 @@ impl Mode {
|
||||
pub struct RealmState {
|
||||
pub active: Mode,
|
||||
pub wildcard_created: bool,
|
||||
#[serde(default)]
|
||||
pub simultaneous: bool,
|
||||
#[serde(default)]
|
||||
pub secondary_world_port: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub secondary_ra_port: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -49,6 +55,9 @@ pub struct View {
|
||||
pub wildcard_created: bool,
|
||||
pub supported: bool,
|
||||
pub recovery_pending: bool,
|
||||
pub simultaneous: bool,
|
||||
pub secondary_world_port: Option<u16>,
|
||||
pub secondary_running: bool,
|
||||
}
|
||||
|
||||
pub fn state(root: &Path) -> Result<RealmState> {
|
||||
@@ -61,7 +70,7 @@ pub fn view(root: &Path) -> Result<View> {
|
||||
// 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() })
|
||||
Ok(View { active: s.active, wildcard_created: s.wildcard_created, supported, recovery_pending: root.join(JOURNAL).exists(), simultaneous: s.simultaneous, secondary_world_port: s.secondary_world_port, secondary_running: crate::multiworld::is_running(root) })
|
||||
}
|
||||
|
||||
pub fn guard_module(root: &Path, id: &str) -> Result<()> {
|
||||
@@ -138,7 +147,7 @@ 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 {
|
||||
if observed.world.state != process::ServiceState::Stopped || observed.auth.state != process::ServiceState::Stopped || crate::multiworld::is_running(root) {
|
||||
return Err(Error::Invalid("Stop the server before switching realms. Your characters will be saved.".into()));
|
||||
}
|
||||
Ok(())
|
||||
@@ -258,11 +267,22 @@ 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())?; }
|
||||
if patched != source {
|
||||
fsx::atomic_write(&path, patched.as_bytes())?;
|
||||
// A Manager-owned transformation is still pristine for subsequent updates and file checks.
|
||||
if let Ok(dir) = crate::registry::metadata_dir_for(root) {
|
||||
if let Ok((_, mut meta)) = crate::registry::MetaDir::open(&dir) {
|
||||
if meta.original_hashes.get("Scripts/manage.py").is_some_and(|hash| hash == &fsx::sha256_bytes(source.as_bytes())) {
|
||||
meta.original_hashes.insert("Scripts/manage.py".into(), fsx::sha256_bytes(patched.as_bytes()));
|
||||
fsx::atomic_write_json(&dir.join("install.json"), &meta)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn patch_launcher(source: &str) -> Result<String> {
|
||||
pub(crate) 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\")"),
|
||||
@@ -288,6 +308,8 @@ pub fn before_start(root: &Path) -> Result<()> {
|
||||
let s = state(root)?;
|
||||
if !root.join(STATE).exists() { return Ok(()); }
|
||||
prepare_launcher(root)?;
|
||||
if s.simultaneous && !view(root)?.supported { return Err(Error::Invalid("The installed server build does not support simultaneous Wildcard startup.".into())); }
|
||||
if s.simultaneous { fsx::atomic_write_json(&snapshot(root, s.active), &configs(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");
|
||||
@@ -304,6 +326,11 @@ pub fn setup_realmlist(root: &Path) -> Result<()> {
|
||||
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()))?;
|
||||
if s.simultaneous {
|
||||
let second = s.secondary_world_port.ok_or_else(|| Error::Invalid("Second realm port is missing.".into()))?;
|
||||
let (coa, wildcard) = if s.active == Mode::Coa { (port, second) } else { (second, port) };
|
||||
db.query(&format!("UPDATE acore_auth.realmlist SET port=CASE id WHEN 1 THEN {coa} ELSE {wildcard} END,flag=0 WHERE id IN (1,2);"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -358,7 +385,7 @@ mod tests {
|
||||
#[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();
|
||||
fsx::atomic_write_json(&t.path().join(STATE), &RealmState { active: Mode::Wildcard, wildcard_created: true, ..Default::default() }).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());
|
||||
@@ -368,4 +395,28 @@ mod tests {
|
||||
fn unknown_launcher_is_refused_without_partial_patching() {
|
||||
assert!(patch_launcher("print('custom launcher')").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_launcher_patch_remains_pristine_but_external_edits_conflict() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let root = d.path().join("server");
|
||||
let source = "(\"WorldDatabaseInfo\", \"acore_world\")\n(\"CharacterDatabaseInfo\", \"acore_characters\")\nSET name='AzerothCore',address=\nWHERE id=1;\nmysql(\"UPDATE acore_auth.realmlist SET flag=0\nif __name__ == \"__main__\":\n";
|
||||
fsx::atomic_write(&root.join("Scripts/manage.py"), source.as_bytes()).unwrap();
|
||||
let mut meta = crate::registry::InstallMeta::new(crate::registry::InstallKind::New, &root);
|
||||
meta.original_hashes.insert("Scripts/manage.py".into(), fsx::sha256_bytes(source.as_bytes()));
|
||||
let dir = crate::registry::MetaDir::create(&root, &meta).unwrap();
|
||||
prepare_launcher(&root).unwrap();
|
||||
let (_, meta) = crate::registry::MetaDir::open(&dir.root).unwrap();
|
||||
assert!(crate::diag::verify_managed(&root, &meta).is_empty());
|
||||
let manifest: crate::manifest::Manifest = serde_json::from_value(serde_json::json!({
|
||||
"schema": 1, "kind": "update", "version": "2.0.0", "core": {"commit": null}, "builtAt": "fixture", "minManagerVersion": "0.1.0",
|
||||
"files": [{"path":"Scripts/manage.py","sha256":fsx::sha256_bytes(b"next release"),"size":12,"owner":"core","policy":"replace"}]
|
||||
})).unwrap();
|
||||
assert_eq!(crate::update::plan(&root, &meta, &manifest, &BTreeMap::new(), None).unwrap()[0].action, crate::update::Action::Replace);
|
||||
let mut modified = fs::read(root.join("Scripts/manage.py")).unwrap();
|
||||
modified.extend_from_slice(b"# custom edit\n");
|
||||
fsx::atomic_write(&root.join("Scripts/manage.py"), &modified).unwrap();
|
||||
prepare_launcher(&root).unwrap();
|
||||
assert_eq!(crate::update::plan(&root, &meta, &manifest, &BTreeMap::new(), None).unwrap()[0].action, crate::update::Action::Conflict);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Repair official files and pending SQL from signed packages without replacing player databases or settings.
|
||||
use std::{collections::BTreeMap, fs, path::Path};
|
||||
use serde::Serialize;
|
||||
use crate::{Error, Result, fsx, manifest::{Manifest, Kind, FileEntry, ReplacePolicy}, pkgsource::{self, Source}, registry::{InstallMeta, MetaDir}, update::{Env, RepackEnv}, download::Cancel};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DatabaseCheck {
|
||||
pub realm: crate::realms::Mode,
|
||||
pub migrations: Vec<crate::migrations::Item>,
|
||||
pub problems: Vec<crate::schema_check::Problem>,
|
||||
pub full_schema: bool,
|
||||
}
|
||||
|
||||
fn installed_manifest(meta: &InstallMeta, dir: &Path) -> Result<Manifest> {
|
||||
let version = meta.core.version.as_deref().ok_or_else(|| Error::Invalid("This imported server has no verified package version. Install an official server package first.".into()))?;
|
||||
let update = dir.join("manifests").join(format!("update-{version}.json"));
|
||||
let bytes = fs::read(if update.exists() { update } else { dir.join("manifests/base.json") })?;
|
||||
let m = Manifest::parse(&bytes)?;
|
||||
if m.version != version { return Err(Error::Invalid("The installed package manifest is missing. Update the server to restore its package information.".into())); }
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
pub fn check(root: &Path, dir: &Path) -> Result<Vec<DatabaseCheck>> {
|
||||
let (_, meta) = MetaDir::open(dir)?;
|
||||
let m = installed_manifest(&meta, dir)?;
|
||||
let checks = crate::backup::with_database(root, |db| {
|
||||
let mut modes = vec![crate::realms::Mode::Coa];
|
||||
if crate::realms::state(root)?.wildcard_created { modes.push(crate::realms::Mode::Wildcard); }
|
||||
let mut checks = Vec::new();
|
||||
for mode in modes {
|
||||
let db = db.clone().for_realm(mode);
|
||||
let migrations: Vec<_> = m.migrations.iter().filter(|m| mode == crate::realms::Mode::Coa || m.db != "auth").cloned().collect();
|
||||
checks.push(DatabaseCheck { realm: mode, migrations: crate::migrations::status(&db, &migrations)?, problems: crate::schema_check::check(&db, root)?, full_schema: root.join(crate::schema_check::CONTRACT).exists() });
|
||||
}
|
||||
Ok(checks)
|
||||
})?;
|
||||
fsx::atomic_write_json(&dir.join("logs/database-checks.json"), &checks)?;
|
||||
Ok(checks)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Report {
|
||||
pub restored: Vec<String>,
|
||||
pub applied: Vec<String>,
|
||||
pub backup: String,
|
||||
pub database: Vec<DatabaseCheck>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
fn repairable(f: &FileEntry) -> bool {
|
||||
let path = f.path.to_lowercase();
|
||||
f.owner != crate::manifest::Owner::User && matches!(f.policy, ReplacePolicy::Replace | ReplacePolicy::ReplaceIfPristine)
|
||||
&& !path.starts_with("settings/") && !path.starts_with("core/configs/")
|
||||
&& !path.starts_with("mysql/data/") && !path.starts_with("_migrations/")
|
||||
}
|
||||
|
||||
fn restore_files(root: &Path, work: &Path, before: &Path, broken: &[(&FileEntry, bool)]) -> Result<Vec<String>> {
|
||||
let mut plan = Vec::new();
|
||||
// Back up every target before changing the first one, and retain a crash-recovery inventory.
|
||||
for (entry, _) in broken {
|
||||
let target = fsx::ensure_within(root, &fsx::safe_join(root, &entry.path)?)?;
|
||||
let existed = target.is_file();
|
||||
if existed {
|
||||
let saved = fsx::safe_join(before, &entry.path)?;
|
||||
fs::create_dir_all(saved.parent().unwrap())?;
|
||||
fs::copy(&target, saved)?;
|
||||
}
|
||||
plan.push((entry.path.clone(), existed));
|
||||
}
|
||||
fsx::atomic_write_json(&before.join("files.json"), &plan)?;
|
||||
let mut restored = Vec::new();
|
||||
let result: Result<()> = (|| {
|
||||
for (entry, latest) in broken {
|
||||
let target = fsx::ensure_within(root, &fsx::safe_join(root, &entry.path)?)?;
|
||||
let source = fsx::safe_join(&work.join(if *latest { "update" } else { "base" }), &entry.path)?;
|
||||
fsx::atomic_write(&target, &fs::read(&source)?)?;
|
||||
restored.push(entry.path.clone());
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
for (path, existed) in &plan {
|
||||
let target = fsx::ensure_within(root, &fsx::safe_join(root, path)?)?;
|
||||
if *existed { fsx::atomic_write(&target, &fs::read(fsx::safe_join(before, path)?)?)?; }
|
||||
else if target.is_file() { fs::remove_file(target)?; }
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
Ok(restored)
|
||||
}
|
||||
|
||||
pub fn run(root: &Path, dir: &Path, base_source: &Source, update_source: &Source, key: &str, progress: &dyn Fn(&str, u8)) -> Result<Report> {
|
||||
let env = RepackEnv { root, meta_dir: dir };
|
||||
env.ensure_stopped()?;
|
||||
if crate::update::unfinished(dir).is_some() { return Err(Error::Invalid("Resolve the unfinished update before repairing the server.".into())); }
|
||||
let (_, mut meta) = MetaDir::open(dir)?;
|
||||
progress("Checking signed packages", 2);
|
||||
let (update, _) = pkgsource::fetch_manifest(update_source, key)?;
|
||||
if update.kind != Kind::Update || !update.compatible_with_manager(crate::MANAGER_VERSION) || meta.core.version.as_deref() != Some(&update.version) {
|
||||
return Err(Error::Invalid("Update the server to the latest compatible package before repairing it. Repair never changes its version.".into()));
|
||||
}
|
||||
let (base, _) = pkgsource::fetch_manifest(base_source, key)?;
|
||||
let installed_base = Manifest::parse(&fs::read(dir.join("manifests/base.json"))?)?;
|
||||
if base.kind != Kind::Base || base.version != installed_base.version || base.core.commit != installed_base.core.commit {
|
||||
return Err(Error::Invalid("The matching base package is no longer available. Your server was left unchanged.".into()));
|
||||
}
|
||||
let work = dir.join("staging").join(format!("repair-{}", uuid::Uuid::new_v4()));
|
||||
fs::create_dir_all(&work)?;
|
||||
let result = (|| {
|
||||
let mut files: BTreeMap<String, (&FileEntry, bool)> = base.files.iter().filter(|f| repairable(f)).map(|f| (f.path.clone(), (f, false))).collect();
|
||||
for f in update.files.iter().filter(|f| repairable(f)) { files.insert(f.path.clone(), (f, true)); }
|
||||
let mut broken = Vec::new();
|
||||
for (path, (entry, latest)) in files {
|
||||
let target = fsx::ensure_within(root, &fsx::safe_join(root, &path)?)?;
|
||||
if fsx::sha256_file(&target).ok().as_deref() != Some(&entry.sha256) { broken.push((entry, latest)); }
|
||||
}
|
||||
let cancel = Cancel::default();
|
||||
let update_parts = pkgsource::fetch_parts(update_source, &update, &dir.join("staging/download"), &cancel, &|f,_| progress("Downloading repair files", 5 + (f * 30.0) as u8))?;
|
||||
crate::package::extract(&update_parts, &update, &work.join("update"), &|_,_| {})?;
|
||||
if broken.iter().any(|(_,latest)| !latest) {
|
||||
let base_parts = pkgsource::fetch_parts(base_source, &base, &dir.join("staging/repair-base-download"), &cancel, &|f,_| progress("Downloading base files", 35 + (f * 25.0) as u8))?;
|
||||
crate::package::extract(&base_parts, &base, &work.join("base"), &|_,_| {})?;
|
||||
}
|
||||
// Verify all sources before taking a backup or replacing any target.
|
||||
for (entry, latest) in &broken {
|
||||
let src = fsx::safe_join(&work.join(if *latest { "update" } else { "base" }), &entry.path)?;
|
||||
if fsx::sha256_file(&src)? != entry.sha256 { return Err(Error::Invalid("A repair file failed verification.".into())); }
|
||||
}
|
||||
progress("Backing up before repair", 65);
|
||||
let backup = crate::backup::create(root, dir, crate::backup::Kind::Full, crate::backup::Trigger::Manual, Some("before repair".into()), &|_| {})?.id;
|
||||
let before = dir.join("repairs").join(&backup);
|
||||
fs::create_dir_all(&before)?;
|
||||
fsx::require_space(root, broken.iter().map(|(e,_)| e.size.saturating_mul(2)).sum())?;
|
||||
let restored = restore_files(root, &work, &before, &broken)?;
|
||||
for (entry, _) in &broken {
|
||||
meta.original_hashes.insert(entry.path.clone(), entry.sha256.clone());
|
||||
}
|
||||
fsx::atomic_write_json(&dir.join("install.json"), &meta)?;
|
||||
progress("Repairing pending database updates", 80);
|
||||
let (applied, error) = match env.migrate(&update, &work.join("update/_migrations")) {
|
||||
Ok(r) => (r.applied, r.failed.map(|(id,why)| format!("{id}: {why}"))),
|
||||
Err(e) => (Vec::new(), Some(e.to_string())),
|
||||
};
|
||||
crate::config::create_missing_module_configs(root)?;
|
||||
progress("Checking database after repair", 95);
|
||||
let database = check(root, dir)?;
|
||||
let report = Report { restored, applied, backup, database, error };
|
||||
fsx::atomic_write_json(&before.join("report.json"), &report)?;
|
||||
progress("Done", 100);
|
||||
Ok(report)
|
||||
})();
|
||||
// Only the UUID staging directory created above is discarded; backups are retained.
|
||||
let _ = fs::remove_dir_all(work);
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn repair_never_replaces_player_data_or_settings() {
|
||||
for path in ["mysql/data/acore_characters/characters.ibd", "Settings/repack.json", "Core/configs/worldserver.conf", "_migrations/world/x.sql"] {
|
||||
let entry = FileEntry { path: path.into(), sha256: "a".repeat(64), size: 1, owner: crate::manifest::Owner::Core, policy: ReplacePolicy::Replace };
|
||||
assert!(!repairable(&entry));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_failure_restores_every_original_and_keeps_recovery_inventory() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let root = d.path().join("server");
|
||||
let work = d.path().join("work");
|
||||
let before = d.path().join("before");
|
||||
fsx::atomic_write(&root.join("Core/a.dll"), b"original").unwrap();
|
||||
fsx::atomic_write(&work.join("update/Core/a.dll"), b"repaired").unwrap();
|
||||
let entries: Vec<_> = ["Core/a.dll", "Core/missing.dll"].into_iter().map(|path| FileEntry { path: path.into(), sha256: "a".repeat(64), size: 1, owner: crate::manifest::Owner::Core, policy: ReplacePolicy::Replace }).collect();
|
||||
assert!(restore_files(&root, &work, &before, &[(&entries[0], true), (&entries[1], true)]).is_err());
|
||||
assert_eq!(fs::read(root.join("Core/a.dll")).unwrap(), b"original");
|
||||
assert!(!root.join("Core/missing.dll").exists());
|
||||
assert!(before.join("files.json").is_file());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Read-only database structure checks. A release can ship a contract captured from its clean database.
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::Path;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::{db::Db, error::Result, fsx};
|
||||
|
||||
pub const CONTRACT: &str = "Scripts/database-schema.json";
|
||||
type Columns = BTreeMap<String, BTreeMap<String, BTreeMap<String, String>>>;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Contract {
|
||||
pub schema: u32,
|
||||
pub columns: Columns,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Problem { pub database: String, pub table: String, pub column: String, pub detail: String }
|
||||
|
||||
fn read_columns(db: &Db, kind: &str) -> Result<BTreeMap<String, BTreeMap<String, String>>> {
|
||||
let schema = db.realm_schema(crate::db::schema_of(kind)?);
|
||||
let text = db.query(&format!("SELECT TABLE_NAME,COLUMN_NAME,CONCAT(COLUMN_TYPE,'|',IS_NULLABLE,'|',IF(COLUMN_DEFAULT IS NULL,'<NULL>',HEX(COLUMN_DEFAULT)),'|',IF(EXTRA='','<NONE>',HEX(EXTRA))) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='{schema}' ORDER BY TABLE_NAME,ORDINAL_POSITION;"))?;
|
||||
let mut tables: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
|
||||
for line in text.lines() {
|
||||
let fields: Vec<_> = line.split('\t').collect();
|
||||
if fields.len() == 3 { tables.entry(fields[0].into()).or_default().insert(fields[1].into(), fields[2].into()); }
|
||||
}
|
||||
Ok(tables)
|
||||
}
|
||||
|
||||
pub fn capture(db: &Db, root: &Path) -> Result<()> {
|
||||
let mut columns = Columns::new();
|
||||
for kind in ["auth", "characters", "world"] { columns.insert(kind.into(), read_columns(db, kind)?); }
|
||||
fsx::atomic_write_json(&root.join(CONTRACT), &Contract { schema: 1, columns })
|
||||
}
|
||||
|
||||
pub fn check(db: &Db, root: &Path) -> Result<Vec<Problem>> {
|
||||
let contract = if root.join(CONTRACT).is_file() {
|
||||
let contract: Contract = fsx::read_json(&root.join(CONTRACT))?;
|
||||
if contract.schema != 1 { return Err(crate::Error::Invalid("Unsupported database schema contract.".into())); }
|
||||
contract
|
||||
} else {
|
||||
// Older packages have no full contract. Check the columns used by initial character saves.
|
||||
let tables: BTreeMap<String, Vec<String>> = serde_json::from_str(include_str!("character-save-columns.json"))?;
|
||||
Contract { schema: 1, columns: BTreeMap::from([("characters".into(), tables.into_iter().map(|(t, cols)| (t, cols.into_iter().map(|c| (c, String::new())).collect())).collect())]) }
|
||||
};
|
||||
let mut problems = Vec::new();
|
||||
for (kind, expected) in &contract.columns {
|
||||
let actual = read_columns(db, kind)?;
|
||||
for (table, cols) in expected {
|
||||
if matches!(table.as_str(), "updates" | "updates_include" | "coa_manager_migrations") { continue; }
|
||||
if !actual.contains_key(table) {
|
||||
problems.push(Problem { database: kind.clone(), table: table.clone(), column: String::new(), detail: "Missing table".into() });
|
||||
continue;
|
||||
}
|
||||
for (column, want) in cols {
|
||||
let found = actual[table].get(column);
|
||||
if found.is_none() || (!want.is_empty() && found != Some(want)) {
|
||||
problems.push(Problem { database: kind.clone(), table: table.clone(), column: column.clone(), detail: found.map(|s| format!("Expected {want}, found {s}")).unwrap_or_else(|| "Missing column".into()) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A custom NOT NULL column without a default can reject every INSERT even when all standard columns exist.
|
||||
let supplied: BTreeMap<String, Vec<String>> = serde_json::from_str(include_str!("character-save-columns.json"))?;
|
||||
let schema = db.realm_schema("acore_characters");
|
||||
let required = db.query(&format!("SELECT TABLE_NAME,COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA='{schema}' AND IS_NULLABLE='NO' AND COLUMN_DEFAULT IS NULL AND EXTRA NOT LIKE '%auto_increment%' AND EXTRA NOT LIKE '%GENERATED%';"))?;
|
||||
for (table, column) in required.lines().filter_map(|l| l.split_once('\t')) {
|
||||
if supplied.get(table).is_some_and(|cols| !cols.iter().any(|c| c == column)) {
|
||||
problems.push(Problem { database: "characters".into(), table: table.into(), column: column.into(), detail: "Required column has no default and is not supplied by character creation".into() });
|
||||
}
|
||||
}
|
||||
// Check every CoA class represented in the starting data, rather than one arbitrary pair.
|
||||
if db.realm() == crate::realms::Mode::Coa {
|
||||
let rows = db.query("SELECT race,class FROM acore_world.playercreateinfo;")?;
|
||||
let pairs: BTreeSet<_> = rows.lines().filter_map(|l| l.split_once('\t')).map(|(a,b)| (a.to_owned(),b.to_owned())).collect();
|
||||
for class in 12..=32 {
|
||||
if !pairs.iter().any(|(_, c)| c == &class.to_string()) {
|
||||
problems.push(Problem { database: "world".into(), table: "playercreateinfo".into(), column: String::new(), detail: format!("No starting data for CoA class {class}") });
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(problems)
|
||||
}
|
||||
@@ -330,7 +330,14 @@ pub fn apply(p: &Params, report: &dyn Fn(&str, u8)) -> Result<Outcome> {
|
||||
meta.core.version = Some(m.version.clone());
|
||||
meta.core.commit = m.core.commit.clone().or(meta.core.commit);
|
||||
for op in &txn.ops {
|
||||
meta.original_hashes.insert(op.path.clone(), op.new_sha256.clone());
|
||||
let hash = if op.path == "Scripts/manage.py" {
|
||||
let staged = fs::read_to_string(tree.join(&op.path)).ok().and_then(|s| crate::realms::patch_launcher(&s).ok());
|
||||
match (staged, fs::read(root.join(&op.path))) {
|
||||
(Some(expected), Ok(actual)) if expected.as_bytes() == actual => fsx::sha256_bytes(&actual),
|
||||
_ => op.new_sha256.clone(),
|
||||
}
|
||||
} else { op.new_sha256.clone() };
|
||||
meta.original_hashes.insert(op.path.clone(), hash);
|
||||
if !meta.managed_files.contains(&op.path) {
|
||||
meta.managed_files.push(op.path.clone());
|
||||
}
|
||||
@@ -462,7 +469,7 @@ impl Env for RepackEnv<'_> {
|
||||
fn ensure_stopped(&self) -> Result<()> {
|
||||
use crate::process::{observe, ServiceState};
|
||||
let o = observe(self.root, &crate::layout::read_ports(self.root));
|
||||
if o.world.state != ServiceState::Stopped || o.auth.state != ServiceState::Stopped {
|
||||
if o.world.state != ServiceState::Stopped || o.auth.state != ServiceState::Stopped || crate::multiworld::is_running(self.root) {
|
||||
return Err(Error::Invalid("Stop the server before updating it; files cannot be replaced while it is running.".into()));
|
||||
}
|
||||
Ok(())
|
||||
@@ -492,6 +499,17 @@ impl Env for RepackEnv<'_> {
|
||||
result.applied.extend(extra.applied);
|
||||
result.failed = extra.failed;
|
||||
if result.snapshot.is_none() { result.snapshot = extra.snapshot; }
|
||||
if result.failed.is_none() {
|
||||
if let Some(p) = crate::schema_check::check(&other_db, root)?.first() {
|
||||
return Err(Error::Invalid(format!("Database validation failed on {}: {}.{}: {}", other.name(), p.table, p.column, p.detail)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.failed.is_none() {
|
||||
let problems = crate::schema_check::check(db, root)?;
|
||||
if let Some(p) = problems.first() {
|
||||
return Err(Error::Invalid(format!("Database validation failed: {}.{}.{}: {} ({} problems).", p.database, p.table, p.column, p.detail, problems.len())));
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
@@ -516,7 +534,7 @@ impl Env for RepackEnv<'_> {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(180);
|
||||
loop {
|
||||
let o = observe(self.root, &ports);
|
||||
if o.mysql.state == ServiceState::Running && o.auth.state == ServiceState::Running && o.world.state == ServiceState::Running {
|
||||
if o.mysql.state == ServiceState::Running && o.auth.state == ServiceState::Running && o.world.state == ServiceState::Running && o.secondary_world.as_ref().is_none_or(|s| s.state == ServiceState::Running) {
|
||||
break true;
|
||||
}
|
||||
if std::time::Instant::now() > deadline {
|
||||
|
||||
@@ -44,6 +44,12 @@ fn run() -> Result<(), String> {
|
||||
let e = |x: coa_core::Error| x.to_string();
|
||||
let part = a.get("part-size").and_then(|v| v.parse().ok()).unwrap_or(DEFAULT_PART_SIZE);
|
||||
match cmd.as_str() {
|
||||
"schema-contract" => {
|
||||
let repack = PathBuf::from(need(&a, "repack")?);
|
||||
let tree = PathBuf::from(need(&a, "tree")?);
|
||||
coa_core::backup::with_database(&repack, |db| coa_core::schema_check::capture(db, &tree)).map_err(e)?;
|
||||
println!("database schema contract captured");
|
||||
}
|
||||
"pack-base" => {
|
||||
let opts = BuildOptions { kind: Kind::Base, version: need(&a, "version")?.clone(), core_commit: a.get("core-commit").cloned(), built_at: chrono_now(), part_size: part, bots_commit: a.get("bots-commit").cloned(), migrations: vec![] };
|
||||
let m = build(&PathBuf::from(need(&a, "tree")?), &PathBuf::from(need(&a, "out")?), &opts, &|s| eprintln!("{s}")).map_err(e)?;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Database checks, repair and simultaneous worlds
|
||||
|
||||
Settings includes **Check files and database**, **Repair**, and **Start CoA and Wildcard together**.
|
||||
|
||||
Database checks inspect both realm schemas when Wildcard exists, the migration ledger, character-save columns and CoA starting class data. Results are saved in the installation metadata's `logs/database-checks.json` and included in exported diagnostics. Hash checks skip mutable MySQL data and configuration files.
|
||||
|
||||
Repair requires both worlds and auth to be stopped, no unfinished update, and the installed version to match the currently published signed update. The signed base must match the installed base as well. It verifies packages before changing files, creates a full database/config backup, saves every replaced file and a recovery inventory under `<server>.manager/repairs/<backup-id>/`, then restores official files and applies pending migrations to both worlds. Settings and player databases are not replaced. A failure during file replacement restores the original files. Interrupted repair can be retried; its file inventory and backups remain available.
|
||||
|
||||
Repair reports unresolved schema damage. It does not guess ALTER statements or replay already applied SQL: those cases need a new corrective migration. Applied SQL whose recorded SHA-256 changed is reported and rejected. Manager failure records take precedence over the core's old `updates` entries.
|
||||
|
||||
## Release schema contracts
|
||||
|
||||
`clean-base` clears the character migration ledger after rebuilding the character database and captures `Scripts/database-schema.json`. It contains expected table/column names, types, nullability, defaults and generated/auto-increment attributes. Installation and migration validation inspect this contract; older packages receive the narrower character-save checks, clearly labelled in the UI. Required custom columns with no default that initial character saves do not supply are also reported.
|
||||
|
||||
For each update, capture a fresh contract **from a disposable database already migrated to the exact packaged core**, into the release tree before `pack-update`:
|
||||
|
||||
```powershell
|
||||
cargo run -p coa-release -- schema-contract --repack C:\games\release-fixture --tree C:\games\release-tree
|
||||
```
|
||||
|
||||
The regular packager includes the contract and its hash in the signed manifest. Never capture it from a user's server or from the previous release database.
|
||||
|
||||
## Simultaneous worlds
|
||||
|
||||
On Windows the checkbox creates Wildcard when needed and saves distinct secondary world/RA ports. Both worlds start with one shared authserver/MySQL and separate world/character databases. The second world has its own executable copy, config, supervisor, logs and report spool in `.realms/secondary`; Data is read through the primary installation's absolute path. This runtime folder is excluded from packages.
|
||||
|
||||
The selected realm remains the primary world used by the Manager's console, companions, settings and Play. Selecting another realm still stops/restarts both to preserve that relationship. CoA companion restrictions on Wildcard remain in force. Both world statuses are shown on Overview.
|
||||
|
||||
Stop shuts down the secondary supervisor/world before the primary launcher may stop the shared database. Database restores, updates and profile changes reject a running secondary world. Each start refreshes its executable/config copies, so server updates cannot leave an old secondary binary behind. The secondary world inherits the primary network bind setting; its RA console remains on loopback. Enabling friends networking opens/maps both world ports. If networking was enabled before the second realm, enable it again to add the second port's rule/mapping.
|
||||
|
||||
## Disposable probes
|
||||
|
||||
```powershell
|
||||
cargo run -p coa-core --example multiworld_e2e -- C:\games\your-fixture
|
||||
cargo run -p coa-core --example repair_e2e -- C:\games\your-fixture
|
||||
cargo run -p coa-core --example schema_e2e -- C:\games\your-fixture
|
||||
```
|
||||
|
||||
The first checks both processes, ports, shared services and graceful shutdown. The second uses a test-only signing key, corrupts a fixture file, applies SQL to both worlds, verifies backups and account/character counts, and drops a column only in Wildcard to prove schema checks are isolated. These probes modify the fixture; never use a real server.
|
||||
+3
-3
@@ -157,7 +157,7 @@
|
||||
"type": "float",
|
||||
"category": "rates",
|
||||
"title": "Rested bonus while playing",
|
||||
"description": "Speed at which rested experience is used up while you play.",
|
||||
"description": "Speed at which rested experience accumulates while online in a rest area.",
|
||||
"default": 1,
|
||||
"advanced": true,
|
||||
"restartRequired": "world",
|
||||
@@ -637,8 +637,8 @@
|
||||
"key": "Rate.Talent",
|
||||
"type": "float",
|
||||
"category": "rates",
|
||||
"title": "Talent points",
|
||||
"description": "Multiplier for the talent points a character earns per level.",
|
||||
"title": "Standard talent points (AzerothCore)",
|
||||
"description": "Only affects standard AzerothCore talents. CoA class and specialization points come from client data and ignore this multiplier.",
|
||||
"default": 1,
|
||||
"advanced": true,
|
||||
"restartRequired": "world",
|
||||
|
||||
+37
-14
@@ -558,6 +558,32 @@ async fn realm_select(state: State<'_, AppState>, id: String, mode: coa_core::re
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn realm_simultaneous(state: State<'_, AppState>, id: String, enabled: bool) -> std::result::Result<coa_core::realms::View, UiError> {
|
||||
let root = path_of(&state, &id)?;
|
||||
let _guard = BusyGuard::acquire(&state, &id)?;
|
||||
blocking(move || coa_core::multiworld::set_enabled(&root, enabled)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn check_database(state: State<'_, AppState>, id: String) -> std::result::Result<Vec<coa_core::repair::DatabaseCheck>, UiError> {
|
||||
let root = path_of(&state, &id)?;
|
||||
let _guard = BusyGuard::acquire(&state, &id)?;
|
||||
blocking(move || coa_core::repair::check(&root, &meta_dir(&root)?)).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn repair_server(app: AppHandle, state: State<'_, AppState>, id: String) -> std::result::Result<coa_core::repair::Report, UiError> {
|
||||
let root = path_of(&state, &id)?;
|
||||
let _guard = BusyGuard::acquire(&state, &id)?;
|
||||
blocking(move || {
|
||||
let dir = meta_dir(&root)?;
|
||||
coa_core::repair::run(&root, &dir, &package_source(None), &update_source(None), coa_core::signing::EMBEDDED_PUBLIC_KEY, &|step,percent| {
|
||||
let _ = app.emit("repair-progress", serde_json::json!({ "id": id, "step": step, "percent": percent }));
|
||||
})
|
||||
}).await
|
||||
}
|
||||
|
||||
#[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)?;
|
||||
@@ -689,7 +715,7 @@ async fn apply_update(
|
||||
let (dir, _) = install_meta(&root)?;
|
||||
// Files cannot be replaced while the server runs: stop it first (gracefully), like the Stop button.
|
||||
let observed = coa_core::process::observe(&root, &layout::read_ports(&root));
|
||||
if observed.world.state != coa_core::process::ServiceState::Stopped || observed.auth.state != coa_core::process::ServiceState::Stopped {
|
||||
if observed.world.state != coa_core::process::ServiceState::Stopped || observed.auth.state != coa_core::process::ServiceState::Stopped || coa_core::multiworld::is_running(&root) {
|
||||
let out = driver::run(&root, Verb::StopAll)?;
|
||||
if !out.ok {
|
||||
return Err(Error::Invalid("The server could not be stopped, so the update was not started.".into()));
|
||||
@@ -1120,7 +1146,7 @@ fn end_client_job(state: &AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the server if needed, wait until it is ready, then launch the game client.
|
||||
/// Launch the game client independently of the local server.
|
||||
#[tauri::command]
|
||||
async fn play(state: State<'_, AppState>, id: String) -> std::result::Result<DriverOutcome, UiError> {
|
||||
let root = path_of(&state, &id)?;
|
||||
@@ -1129,17 +1155,6 @@ async fn play(state: State<'_, AppState>, id: String) -> std::result::Result<Dri
|
||||
if coa_core::client::is_running(&client) {
|
||||
return Err(Error::Invalid("Close the game client before pressing Play so its selected realm can be updated.".into()).into());
|
||||
}
|
||||
let ready = {
|
||||
let o = coa_core::process::observe(&root, &layout::read_ports(&root));
|
||||
[&o.mysql, &o.auth, &o.world].iter().all(|s| s.state == coa_core::process::ServiceState::Running)
|
||||
};
|
||||
if !ready {
|
||||
let out = run_verb(&state, id.clone(), Verb::StartAll).await?;
|
||||
if !out.ok {
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
let _guard = BusyGuard::acquire(&state, &id)?;
|
||||
blocking(move || {
|
||||
let mode = coa_core::realms::state(&root)?.active;
|
||||
if !coa_core::client::sync_realm(&client, &dir, mode)? {
|
||||
@@ -1163,6 +1178,7 @@ struct FriendsStatus {
|
||||
server_running: bool,
|
||||
auth_port: u16,
|
||||
world_port: u16,
|
||||
secondary_world_port: Option<u16>,
|
||||
}
|
||||
|
||||
/// Open one of a few known help pages in the default browser. Anything else is refused, so a page can never ask the
|
||||
@@ -1243,6 +1259,7 @@ async fn friends_status(state: State<'_, AppState>, id: String) -> std::result::
|
||||
server_running: coa_core::process::observe(&root, &ports).world.state == coa_core::process::ServiceState::Running,
|
||||
auth_port: ports.auth,
|
||||
world_port: ports.world,
|
||||
secondary_world_port: coa_core::realms::state(&root)?.secondary_world_port.filter(|_| coa_core::realms::state(&root).is_ok_and(|s| s.simultaneous)),
|
||||
})
|
||||
})
|
||||
.await
|
||||
@@ -1296,6 +1313,8 @@ async fn friends_enable(
|
||||
let meta = meta_dir(&root)?;
|
||||
let ports = layout::read_ports(&root);
|
||||
let mut note = None;
|
||||
let realms = coa_core::realms::state(&root)?;
|
||||
let secondary = realms.secondary_world_port.filter(|_| realms.simultaneous);
|
||||
let host = match mode {
|
||||
Mode::Local => "127.0.0.1".to_string(),
|
||||
Mode::Lan => coa_core::net::lan_ip().ok_or_else(|| Error::Invalid("This computer has no network address.".into()))?.to_string(),
|
||||
@@ -1305,7 +1324,7 @@ async fn friends_enable(
|
||||
let open = mode != Mode::Local;
|
||||
let changed = friends::set_open(&root, &meta, open)?;
|
||||
if open {
|
||||
coa_core::firewall::ensure_rules(&ports)?;
|
||||
coa_core::firewall::ensure_rules_with_secondary(&ports, secondary)?;
|
||||
}
|
||||
if mode == Mode::Direct && use_upnp {
|
||||
match coa_core::upnp::discover() {
|
||||
@@ -1313,6 +1332,7 @@ async fn friends_enable(
|
||||
let lan = coa_core::net::lan_ip().ok_or_else(|| Error::Invalid("No local address.".into()))?;
|
||||
coa_core::upnp::add_mapping(&gw, ports.auth, lan, "Auth")?;
|
||||
coa_core::upnp::add_mapping(&gw, ports.world, lan, "World")?;
|
||||
if let Some(port) = secondary { coa_core::upnp::add_mapping(&gw, port, lan, "Second world")?; }
|
||||
note = Some("Your router was asked to forward the game ports.".to_string());
|
||||
}
|
||||
None => note = Some("Your router does not support automatic setup; forward the two game ports by hand or use the private network.".to_string()),
|
||||
@@ -1453,6 +1473,9 @@ pub fn run() {
|
||||
modules_list,
|
||||
realm_profiles,
|
||||
realm_select,
|
||||
realm_simultaneous,
|
||||
check_database,
|
||||
repair_server,
|
||||
module_set_enabled,
|
||||
module_settings,
|
||||
module_save_settings,
|
||||
|
||||
+7
-3
@@ -8,6 +8,7 @@ export {};
|
||||
(() => {
|
||||
let added = new URLSearchParams(location.search).has("demo"), running = false;
|
||||
let realm: "coa" | "wildcard" = "coa", wildcardCreated = false;
|
||||
let simultaneous = 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:[
|
||||
{key:"worldserver",label:"World server",status:"found",detail:null},
|
||||
@@ -22,12 +23,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==="realm_profiles") return {active:realm,wildcard_created:wildcardCreated,supported:true,recovery_pending:false,simultaneous,secondary_world_port:simultaneous?8086:null,secondary_running:simultaneous&&running};
|
||||
if(cmd==="realm_simultaneous") { if(running) throw new Error("Stop both realms before changing simultaneous startup."); simultaneous=args.enabled; wildcardCreated ||= simultaneous; return {active:realm,wildcard_created:wildcardCreated,supported:true,recovery_pending:false,simultaneous,secondary_world_port:simultaneous?8086:null,secondary_running:false}; }
|
||||
if(cmd==="check_database") return [{realm, migrations:[],problems:[],full_schema:false}];
|
||||
if(cmd==="repair_server") { if(running) throw new Error("Stop the server before repairing it."); return {restored:[],applied:[],backup:"demo-before-repair",database:[{realm,migrations:[],problems:[],full_schema:false}],error:null}; }
|
||||
if(cmd==="realm_select"){await new Promise(r=>setTimeout(r,600));realm=args.mode;wildcardCreated ||= realm==="wildcard";modState.companions=realm==="coa";return {active:realm,wildcard_created:wildcardCreated,supported:true,recovery_pending:false,simultaneous,secondary_world_port:simultaneous?8086:null,secondary_running:simultaneous&&running};}
|
||||
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==="server_status"){const s=running?"running":"stopped";return {observed:{mysql:svc("mysql",3307,s),auth:svc("auth",3724,s),world:svc("world",8085,s),secondary_world:simultaneous?svc("world-secondary",8086,s):null},busy:false,path_exists:true};}
|
||||
if(cmd==="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";
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
/** English is the source of truth: every key must exist here; other locales fall back to it. */
|
||||
const en = {
|
||||
"realm.profileLabel": "Realm",
|
||||
"realm.startupTitle": "Realm startup",
|
||||
"realm.secondWorld": "Second world",
|
||||
"realm.simultaneous": "Start CoA and Wildcard together",
|
||||
"realm.simultaneousHint": "Stop the server before changing this option. Start runs both worlds with separate characters, settings and ports, sharing accounts. Two worlds need more memory. The first setup creates Wildcard if needed.",
|
||||
"realm.secondPort": "Second world port: {port}",
|
||||
"realm.preparing": "Preparing realm databases…",
|
||||
"realm.bothRunning": "Both worlds start together. Each realm keeps its own characters, progress and module settings.",
|
||||
"repair.title": "Check and repair server",
|
||||
"repair.hint": "Stop both realms and update to the latest server package before repair. Repair backs up databases and replaced files, restores official program files and applies pending SQL. Your characters and existing settings are preserved.",
|
||||
"repair.check": "Check files and database",
|
||||
"repair.checking": "Checking…",
|
||||
"repair.run": "Repair",
|
||||
"repair.running": "Repairing… {n}%",
|
||||
"repair.result": "Restored files: {files}. Applied database updates: {sql}.",
|
||||
"repair.backup": "Backup: {id}",
|
||||
"repair.fileCount": "Files that differ or are missing: {n}",
|
||||
"repair.dbCount": "Schema/start-data problems: {columns}. Pending or failed SQL updates: {sql}.",
|
||||
"repair.limited": "This older package has no full schema contract. Only character-save columns and starting data are checked.",
|
||||
"repair.unresolved": "Database problems remain. Repair does not replay already applied SQL or invent column changes. Export diagnostics with the listed problems for a corrective server update.",
|
||||
"realm.switching": "Switching realm…",
|
||||
"realm.create": "Create Wildcard realm",
|
||||
"realm.restart": "Switch and restart",
|
||||
|
||||
@@ -3,6 +3,25 @@ import type en from "./en";
|
||||
/** Русский. Черновой перевод; требует проверки носителем перед релизом. */
|
||||
const ru: Partial<Record<keyof typeof en, string>> = {
|
||||
"realm.profileLabel": "Реалм",
|
||||
"realm.startupTitle": "Запуск реалмов",
|
||||
"realm.secondWorld": "Второй мир",
|
||||
"realm.simultaneous": "Запускать CoA и Wildcard одновременно",
|
||||
"realm.simultaneousHint": "Остановите сервер перед изменением настройки. Запуск поднимет оба мира с отдельными персонажами, настройками и портами, но общими аккаунтами. Два мира требуют больше памяти. При первом включении Wildcard будет создан, если его ещё нет.",
|
||||
"realm.secondPort": "Порт второго мира: {port}",
|
||||
"realm.preparing": "Подготовка баз реалма…",
|
||||
"realm.bothRunning": "Оба мира запускаются одновременно. У каждого свои персонажи, прогресс и настройки модулей.",
|
||||
"repair.title": "Проверка и восстановление сервера",
|
||||
"repair.hint": "Перед восстановлением остановите оба реалма и обновите сервер до последнего пакета. Repair сохранит резервную копию БД и заменяемых файлов, восстановит файлы программы и применит недостающие SQL. Персонажи и существующие настройки сохраняются.",
|
||||
"repair.check": "Проверить файлы и БД",
|
||||
"repair.checking": "Проверка…",
|
||||
"repair.run": "Восстановить",
|
||||
"repair.running": "Восстановление… {n}%",
|
||||
"repair.result": "Восстановлено файлов: {files}. Применено обновлений БД: {sql}.",
|
||||
"repair.backup": "Резервная копия: {id}",
|
||||
"repair.fileCount": "Изменённых или отсутствующих файлов: {n}",
|
||||
"repair.dbCount": "Проблем структуры БД/стартовых данных: {columns}. Ожидающих или неудачных SQL: {sql}.",
|
||||
"repair.limited": "В старом пакете нет полного описания схемы. Проверяются только колонки сохранения персонажей и стартовые данные.",
|
||||
"repair.unresolved": "Проблемы БД остались. Repair не повторяет уже применённые SQL и не добавляет колонки наугад. Экспортируйте диагностику с этим списком для исправляющего обновления сервера.",
|
||||
"realm.switching": "Переключение реалма…",
|
||||
"realm.create": "Создать реалм Wildcard",
|
||||
"realm.restart": "Переключить и перезапустить",
|
||||
|
||||
@@ -349,7 +349,7 @@
|
||||
},
|
||||
"Rate.Rest.InGame": {
|
||||
"t": "Ruhebonus beim Spielen",
|
||||
"d": "Geschwindigkeit, mit der Ruhe-Erfahrung beim Spielen aufgebraucht wird."
|
||||
"d": "Geschwindigkeit, mit der Erholungsbonus online in einem Ruhebereich aufgebaut wird."
|
||||
},
|
||||
"Rate.Creature.Normal.Damage": {
|
||||
"t": "Monsterschaden",
|
||||
@@ -484,8 +484,8 @@
|
||||
"d": "Multiplikator dafür, wie oft legendäre (orange) Gegenstände fallen."
|
||||
},
|
||||
"Rate.Talent": {
|
||||
"t": "Talentpunkte",
|
||||
"d": "Multiplikator für die Talentpunkte, die ein Charakter pro Stufe erhält."
|
||||
"t": "Standard-Talentpunkte (AzerothCore)",
|
||||
"d": "Gilt nur für Standardtalente von AzerothCore. CoA-Klassen- und Spezialisierungspunkte stammen aus Clientdaten und ignorieren diesen Multiplikator."
|
||||
},
|
||||
"Rate.Skill.Discovery": {
|
||||
"t": "Chance auf Rezeptentdeckung",
|
||||
|
||||
@@ -349,7 +349,7 @@
|
||||
},
|
||||
"Rate.Rest.InGame": {
|
||||
"t": "Bonificación de descanso al jugar",
|
||||
"d": "Velocidad a la que se consume la experiencia de descanso mientras juegas."
|
||||
"d": "Velocidad de acumulación de experiencia de descanso mientras estás conectado en una zona de descanso."
|
||||
},
|
||||
"Rate.Creature.Normal.Damage": {
|
||||
"t": "Daño de los monstruos",
|
||||
@@ -484,8 +484,8 @@
|
||||
"d": "Multiplicador de la frecuencia con que caen objetos legendarios (naranjas)."
|
||||
},
|
||||
"Rate.Talent": {
|
||||
"t": "Puntos de talento",
|
||||
"d": "Multiplicador de los puntos de talento que gana un personaje por nivel."
|
||||
"t": "Puntos de talento estándar (AzerothCore)",
|
||||
"d": "Solo afecta a los talentos estándar de AzerothCore. Los puntos de clase y especialización de CoA proceden de los datos del cliente e ignoran este multiplicador."
|
||||
},
|
||||
"Rate.Skill.Discovery": {
|
||||
"t": "Probabilidad de descubrir recetas",
|
||||
|
||||
@@ -349,7 +349,7 @@
|
||||
},
|
||||
"Rate.Rest.InGame": {
|
||||
"t": "Bonus de repos en jeu",
|
||||
"d": "Vitesse à laquelle l’expérience de repos est consommée pendant que vous jouez."
|
||||
"d": "Vitesse d’accumulation du bonus de repos en ligne dans une zone de repos."
|
||||
},
|
||||
"Rate.Creature.Normal.Damage": {
|
||||
"t": "Dégâts des monstres",
|
||||
@@ -484,8 +484,8 @@
|
||||
"d": "Multiplicateur de la fréquence de butin des objets légendaires (orange)."
|
||||
},
|
||||
"Rate.Talent": {
|
||||
"t": "Points de talent",
|
||||
"d": "Multiplicateur des points de talent gagnés par niveau."
|
||||
"t": "Points de talent standards (AzerothCore)",
|
||||
"d": "Affecte uniquement les talents standards AzerothCore. Les points de classe et de spécialisation CoA proviennent des données du client et ignorent ce multiplicateur."
|
||||
},
|
||||
"Rate.Skill.Discovery": {
|
||||
"t": "Chance de découvrir des recettes",
|
||||
|
||||
@@ -349,7 +349,7 @@
|
||||
},
|
||||
"Rate.Rest.InGame": {
|
||||
"t": "Бонус отдыха во время игры",
|
||||
"d": "Скорость, с которой расходуется накопленный отдых во время игры."
|
||||
"d": "Скорость накопления опыта отдыха, пока персонаж находится онлайн в зоне отдыха."
|
||||
},
|
||||
"Rate.Creature.Normal.Damage": {
|
||||
"t": "Урон монстров",
|
||||
@@ -484,8 +484,8 @@
|
||||
"d": "Множитель частоты выпадения легендарных (оранжевых) предметов."
|
||||
},
|
||||
"Rate.Talent": {
|
||||
"t": "Очки талантов",
|
||||
"d": "Множитель очков талантов, которые персонаж получает за уровень."
|
||||
"t": "Обычные очки талантов (AzerothCore)",
|
||||
"d": "Влияет только на обычные таланты AzerothCore. Очки классового дерева и специализации CoA берутся из данных клиента и не зависят от этого множителя."
|
||||
},
|
||||
"Rate.Skill.Discovery": {
|
||||
"t": "Шанс открытия рецептов",
|
||||
|
||||
@@ -349,7 +349,7 @@
|
||||
},
|
||||
"Rate.Rest.InGame": {
|
||||
"t": "在线双倍经验消耗速度",
|
||||
"d": "游戏在线时消耗双倍休息充分经验的速度。"
|
||||
"d": "在线处于休息区域时,休息经验的积累速度。"
|
||||
},
|
||||
"Rate.Creature.Normal.Damage": {
|
||||
"t": "普通怪物伤害倍率",
|
||||
@@ -484,8 +484,8 @@
|
||||
"d": "传说品质(橙色装备)掉落频率的乘数倍率。"
|
||||
},
|
||||
"Rate.Talent": {
|
||||
"t": "每级天赋点数倍率",
|
||||
"d": "角色每升一级所获得天赋点数的乘数倍率。"
|
||||
"t": "标准天赋点(AzerothCore)",
|
||||
"d": "仅影响 AzerothCore 标准天赋。CoA 职业和专精点数来自客户端数据,不受此倍率影响。"
|
||||
},
|
||||
"Rate.Skill.Discovery": {
|
||||
"t": "专业配方领悟几率",
|
||||
|
||||
+13
-2
@@ -45,13 +45,20 @@ export interface ServiceStatus {
|
||||
}
|
||||
|
||||
export interface StatusView {
|
||||
observed: { mysql: ServiceStatus; auth: ServiceStatus; world: ServiceStatus };
|
||||
observed: { mysql: ServiceStatus; auth: ServiceStatus; world: ServiceStatus; secondary_world?: ServiceStatus | null };
|
||||
busy: boolean;
|
||||
path_exists: boolean;
|
||||
}
|
||||
|
||||
export type RealmMode = "coa" | "wildcard";
|
||||
export interface RealmProfiles { active: RealmMode; wildcard_created: boolean; supported: boolean; recovery_pending: boolean }
|
||||
export interface RealmProfiles { active: RealmMode; wildcard_created: boolean; supported: boolean; recovery_pending: boolean; simultaneous: boolean; secondary_world_port: number | null; secondary_running: boolean }
|
||||
export interface DatabaseCheck {
|
||||
realm: RealmMode;
|
||||
migrations: { id: string; db: string; status: "applied" | "pending" | "failed"; error: string | null }[];
|
||||
problems: { database: string; table: string; column: string; detail: string }[];
|
||||
full_schema: boolean;
|
||||
}
|
||||
export interface RepairReport { restored: string[]; applied: string[]; backup: string; database: DatabaseCheck[]; error: string | null }
|
||||
|
||||
export interface Human {
|
||||
code: string;
|
||||
@@ -343,6 +350,7 @@ export interface FriendsStatus {
|
||||
server_running: boolean;
|
||||
auth_port: number;
|
||||
world_port: number;
|
||||
secondary_world_port?: number | null;
|
||||
}
|
||||
|
||||
export interface InternetCheck {
|
||||
@@ -368,6 +376,9 @@ 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 }),
|
||||
realmSimultaneous: (id: string, enabled: boolean) => invoke<RealmProfiles>("realm_simultaneous", { id, enabled }),
|
||||
checkDatabase: (id: string) => invoke<DatabaseCheck[]>("check_database", { id }),
|
||||
repairServer: (id: string) => invoke<RepairReport>("repair_server", { id }),
|
||||
defaultInstallDir: () => invoke<string>("default_install_dir"),
|
||||
scan: (path: string) => invoke<ScanReport>("scan_server", { path }),
|
||||
add: (path: string) => invoke<ServerSummary>("add_server", { path }),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { api, asUiError, type RepairReport } from "@/lib/api";
|
||||
type State = { busy: boolean; percent: number; result: RepairReport | null; error: string | null };
|
||||
const empty: State = { busy: false, percent: 0, result: null, error: null };
|
||||
const states = new Map<string, State>();
|
||||
const listeners = new Set<() => void>();
|
||||
const get = (id: string) => states.get(id) ?? empty;
|
||||
function set(id: string, change: Partial<State>) { states.set(id, { ...get(id), ...change }); listeners.forEach(fn => fn()); }
|
||||
export function useRepair(id: string) {
|
||||
return useSyncExternalStore(fn => { listeners.add(fn); return () => { listeners.delete(fn); }; }, () => get(id));
|
||||
}
|
||||
export function clearRepairResult(id: string) { if (!get(id).busy) set(id, { result: null, error: null }); }
|
||||
export async function repairServer(id: string) {
|
||||
if (get(id).busy) return;
|
||||
set(id, { busy: true, percent: 0, result: null, error: null });
|
||||
let unlisten: (() => void) | undefined;
|
||||
try {
|
||||
unlisten = await listen<{ id: string; percent: number }>("repair-progress", ({ payload }) => { if (payload.id === id) set(id, { percent: payload.percent }); });
|
||||
const result = await api.repairServer(id);
|
||||
set(id, { result, percent: 100 });
|
||||
} catch (e) { set(id, { error: asUiError(e).technical }); }
|
||||
finally { unlisten?.(); set(id, { busy: false }); }
|
||||
}
|
||||
@@ -142,6 +142,7 @@ export function FriendsPage({ serverId }: { serverId: string }) {
|
||||
{t("fr.st.private")}
|
||||
</Line>
|
||||
</ul>
|
||||
{st.secondary_world_port && <p className="mt-3 text-sm text-muted">{t("realm.secondPort", { port: st.secondary_world_port })}</p>}
|
||||
</Card>
|
||||
|
||||
<div className="mt-6 grid gap-3">
|
||||
|
||||
@@ -195,8 +195,8 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o
|
||||
);
|
||||
}
|
||||
|
||||
const { mysql, auth, world } = status.observed;
|
||||
const all = [mysql, auth, world];
|
||||
const { mysql, auth, world, secondary_world } = status.observed;
|
||||
const all = [mysql, auth, world, ...(secondary_world ? [secondary_world] : [])];
|
||||
const running = all.every((s) => s.state === "running");
|
||||
const anyUp = all.some((s) => s.state !== "stopped");
|
||||
const transitioning = action !== null || status.busy || realmBusy;
|
||||
@@ -233,6 +233,7 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o
|
||||
<Row label={t("overview.database")} s={mysql} />
|
||||
<Row label={t("overview.auth")} s={auth} />
|
||||
<Row label={t("overview.world")} s={world} />
|
||||
{secondary_world && <Row label={t("realm.secondWorld")} s={secondary_world} />}
|
||||
</div>
|
||||
|
||||
<dl className="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||
@@ -323,9 +324,9 @@ export function Overview({ server, companions = true, onForget, onOpenUpdates, o
|
||||
{t("btn.updateClient")}
|
||||
</Button>
|
||||
) : client ? (
|
||||
<Button variant={running ? "primary" : "secondary"} size="xl" disabled={transitioning || playing} onClick={() => void play()} className="min-w-40">
|
||||
<Button variant="primary" size="xl" disabled={realmBusy || playing} onClick={() => void play()} className="min-w-40">
|
||||
{playing && <Loader2 className="h-5 w-5 animate-spin" aria-hidden />}
|
||||
{running ? t("btn.play") : t("btn.startPlay")}
|
||||
{t("btn.play")}
|
||||
</Button>
|
||||
) : client === null ? (
|
||||
<Button variant="secondary" size="xl" onClick={() => setDialog("setup")} className="min-w-40">
|
||||
|
||||
@@ -44,7 +44,7 @@ export function RealmPicker({ serverId, running, disabled, onChanged, onBusy }:
|
||||
</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>
|
||||
<p className="mt-3 text-sm text-muted">{t(view?.simultaneous ? "realm.bothRunning" : "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>}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, asUiError, type RealmProfiles } from "@/lib/api";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useT } from "@/i18n";
|
||||
|
||||
export function RealmStartupSettings({ serverId }: { serverId: string }) {
|
||||
const t = useT();
|
||||
const [view, setView] = useState<RealmProfiles | null>(null);
|
||||
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); }).catch(e => { if (alive) setError(asUiError(e).technical); });
|
||||
return () => { alive = false; };
|
||||
}, [serverId]);
|
||||
async function change(enabled: boolean) {
|
||||
setBusy(true); setError(null);
|
||||
try { setView(await api.realmSimultaneous(serverId, enabled)); }
|
||||
catch (e) { setError(asUiError(e).technical); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return <Card className="mt-6 p-6" aria-busy={busy}>
|
||||
<h2 className="font-semibold">{t("realm.startupTitle")}</h2>
|
||||
<label className="mt-3 flex items-center gap-3">
|
||||
<input type="checkbox" checked={view?.simultaneous ?? false} disabled={!view || busy || !view.supported} onChange={e => void change(e.target.checked)} />
|
||||
<span>{t("realm.simultaneous")}</span>
|
||||
</label>
|
||||
<p className="mt-2 text-sm text-muted">{t("realm.simultaneousHint")}</p>
|
||||
{view?.simultaneous && <p className="mt-2 text-sm text-muted">{t("realm.secondPort", { port: view.secondary_world_port ?? "—" })}</p>}
|
||||
{busy && <p className="mt-2 text-sm" role="status">{t("realm.preparing")}</p>}
|
||||
{error && <p className="mt-3 text-sm text-bad" role="alert">{error}</p>}
|
||||
</Card>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from "react";
|
||||
import { api, asUiError, type DatabaseCheck } from "@/lib/api";
|
||||
import { repairServer, useRepair, clearRepairResult } from "@/lib/repairJob";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useT } from "@/i18n";
|
||||
|
||||
export function RepairCard({ serverId }: { serverId: string }) {
|
||||
const t = useT();
|
||||
const job = useRepair(serverId);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [database, setDatabase] = useState<DatabaseCheck[] | null>(null);
|
||||
const [files, setFiles] = useState<{ path: string; kind: string }[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
async function check() {
|
||||
clearRepairResult(serverId);
|
||||
setChecking(true); setError(null); setDatabase(null); setFiles(null);
|
||||
try {
|
||||
setFiles(await api.verifyFiles(serverId));
|
||||
setDatabase(await api.checkDatabase(serverId));
|
||||
} catch (e) { setError(asUiError(e).technical); }
|
||||
finally { setChecking(false); }
|
||||
}
|
||||
const checks = job.result?.database ?? database;
|
||||
const busy = checking || job.busy;
|
||||
return <Card className="mt-6 p-6" aria-busy={busy}>
|
||||
<h2 className="font-semibold">{t("repair.title")}</h2>
|
||||
<p className="mt-2 text-sm text-muted">{t("repair.hint")}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button disabled={busy} onClick={() => void check()}>{checking ? t("repair.checking") : t("repair.check")}</Button>
|
||||
<Button disabled={busy} onClick={() => void repairServer(serverId)}>{job.busy ? t("repair.running", { n: job.percent }) : t("repair.run")}</Button>
|
||||
</div>
|
||||
{job.result && <div className="mt-3 text-sm" role="status">
|
||||
<p>{t("repair.result", { files: job.result.restored.length, sql: job.result.applied.length })}</p>
|
||||
<p className="selectable text-muted">{t("repair.backup", { id: job.result.backup })}</p>
|
||||
{job.result.error && <p className="mt-2 text-bad">{job.result.error}</p>}
|
||||
</div>}
|
||||
{files && !job.result && <p className="mt-3 text-sm">{t("repair.fileCount", { n: files.length })}</p>}
|
||||
{checks?.map(c => {
|
||||
const pending = c.migrations.filter(m => m.status !== "applied");
|
||||
return <div key={c.realm} className="mt-4 border-t border-line pt-3 text-sm">
|
||||
<p className="font-medium">{c.realm === "coa" ? "Conquest of Azeroth" : "Wildcard"}</p>
|
||||
<p className={c.problems.length || pending.length ? "text-warn" : "text-ok"}>{t("repair.dbCount", { columns: c.problems.length, sql: pending.length })}</p>
|
||||
{!c.full_schema && <p className="mt-1 text-muted">{t("repair.limited")}</p>}
|
||||
<ul className="mt-2 max-h-52 overflow-auto selectable text-muted">
|
||||
{c.problems.map((p,i) => <li key={i}>{p.database}.{p.table}{p.column ? `.${p.column}` : ""}: {p.detail}</li>)}
|
||||
{pending.map(m => <li key={m.db + m.id}>{m.db}/{m.id}: {m.status}{m.error ? ` — ${m.error}` : ""}</li>)}
|
||||
</ul>
|
||||
{c.problems.length > 0 && <p className="mt-2 text-warn">{t("repair.unresolved")}</p>}
|
||||
</div>;
|
||||
})}
|
||||
{(error || job.error) && <p className="mt-3 text-sm text-bad" role="alert">{job.error ?? error}</p>}
|
||||
</Card>;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import { DiagnosticsCard } from "@/screens/DiagnosticsCard";
|
||||
import { AboutCard } from "@/screens/AboutCard";
|
||||
import { hasKey, useHuman, useI18n, type Key } from "@/i18n";
|
||||
import { LanguagePicker } from "@/components/LanguagePicker";
|
||||
import { RealmStartupSettings } from "@/screens/RealmStartupSettings";
|
||||
import { RepairCard } from "@/screens/RepairCard";
|
||||
|
||||
function mb(bytes: number) {
|
||||
return bytes >= 1 << 20 ? `${(bytes / (1 << 20)).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
@@ -114,8 +116,10 @@ export function SettingsHome({ serverId }: { serverId: string }) {
|
||||
</Card>
|
||||
|
||||
<ClientCard serverId={serverId} />
|
||||
<RealmStartupSettings serverId={serverId} />
|
||||
|
||||
<DiagnosticsCard serverId={serverId} />
|
||||
<RepairCard key={serverId} serverId={serverId} />
|
||||
|
||||
<AboutCard />
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ function fmt(t: T, sx: SX, v: JsonValue, s: { key: string; type: string; unit?:
|
||||
|
||||
function clientCheck(t: T, s: SettingView, v: JsonValue): string | null {
|
||||
if (s.type === "int" || s.type === "float") {
|
||||
if (typeof v !== "number" || Number.isNaN(v)) return t("set.enterNumber");
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) return t("set.enterNumber");
|
||||
if (s.type === "int" && !Number.isInteger(v)) return t("set.enterInt");
|
||||
if (s.min !== undefined && v < s.min) return t("set.atLeast", { n: s.min });
|
||||
if (s.max !== undefined && v > s.max) return t("set.atMost", { n: s.max });
|
||||
@@ -51,6 +51,8 @@ function Field(props: { s: SettingView; value: JsonValue; error: string | null;
|
||||
const { s, value, error, onChange } = props;
|
||||
const t = useT();
|
||||
const sx = useSchemaText();
|
||||
const [text, setText] = useState(String(value));
|
||||
useEffect(() => setText(String(value)), [value]);
|
||||
const id = `f-${s.key}`;
|
||||
const base = "rounded-md border bg-bg px-3 py-2 text-sm outline-none focus:border-gold";
|
||||
if (s.type === "bool") {
|
||||
@@ -89,12 +91,15 @@ function Field(props: { s: SettingView; value: JsonValue; error: string | null;
|
||||
<input
|
||||
id={id}
|
||||
className={cn(base, numeric ? "w-32 text-right" : "w-72", error ? "border-bad" : "border-line")}
|
||||
value={String(value)}
|
||||
value={text}
|
||||
inputMode={numeric ? "decimal" : undefined}
|
||||
aria-invalid={!!error}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
onChange(numeric ? (raw.trim() === "" || Number.isNaN(Number(raw)) ? (raw as unknown as number) : Number(raw)) : raw);
|
||||
setText(raw);
|
||||
const normalized = raw.replace(",", ".");
|
||||
const number = Number(normalized);
|
||||
onChange(numeric && normalized.trim() !== "" && Number.isFinite(number) ? number : raw);
|
||||
}}
|
||||
/>
|
||||
{s.unit && <span className="text-xs text-muted">{sx.unit(s.unit)}</span>}
|
||||
|
||||
@@ -11,7 +11,7 @@ NEW = [
|
||||
("Rate.Drop.Item.Rare", "float", "rates", "Rare item drop rate", "Multiplier for how often rare (blue) items drop.", 1, 0, 20, "x", False, "world", False),
|
||||
("Rate.Drop.Item.Epic", "float", "rates", "Epic item drop rate", "Multiplier for how often epic (purple) items drop.", 1, 0, 20, "x", False, "world", False),
|
||||
("Rate.Drop.Item.Legendary", "float", "rates", "Legendary item drop rate", "Multiplier for how often legendary (orange) items drop.", 1, 0, 20, "x", False, "world", False),
|
||||
("Rate.Talent", "float", "rates", "Talent points", "Multiplier for the talent points a character earns per level.", 1, 0, 10, "x", True, "world", False),
|
||||
("Rate.Talent", "float", "rates", "Standard talent points (AzerothCore)", "Only affects standard AzerothCore talents. CoA class and specialization points come from client data and ignore this multiplier.", 1, 0, 10, "x", True, "world", False),
|
||||
("Rate.Skill.Discovery", "float", "rates", "Recipe discovery chance", "Multiplier for the chance to discover a new recipe while crafting.", 1, 0, 20, "x", True, "world", False),
|
||||
("Rate.RepairCost", "float", "rates", "Repair cost", "Multiplier for what repairing equipment costs. 0 makes repairs free.", 1, 0, 10, "x", False, "world", False),
|
||||
("Rate.Auction.Cut", "float", "rates", "Auction house cut", "Multiplier for the share of a sale the auction house keeps. 0 means no cut.", 1, 0, 10, "x", True, "world", False),
|
||||
@@ -36,7 +36,7 @@ T = {
|
||||
"Rate.Drop.Item.Rare": ("Шанс выпадения редких предметов", "Множитель частоты выпадения редких (синих) предметов."),
|
||||
"Rate.Drop.Item.Epic": ("Шанс выпадения эпических предметов", "Множитель частоты выпадения эпических (фиолетовых) предметов."),
|
||||
"Rate.Drop.Item.Legendary": ("Шанс выпадения легендарных предметов", "Множитель частоты выпадения легендарных (оранжевых) предметов."),
|
||||
"Rate.Talent": ("Очки талантов", "Множитель очков талантов, которые персонаж получает за уровень."),
|
||||
"Rate.Talent": ("Обычные очки талантов (AzerothCore)", "Влияет только на обычные таланты AzerothCore. Очки классового дерева и специализации CoA берутся из данных клиента и не зависят от этого множителя."),
|
||||
"Rate.Skill.Discovery": ("Шанс открытия рецептов", "Множитель шанса открыть новый рецепт при создании предметов."),
|
||||
"Rate.RepairCost": ("Стоимость ремонта", "Множитель стоимости ремонта снаряжения. 0 делает ремонт бесплатным."),
|
||||
"Rate.Auction.Cut": ("Комиссия аукциона", "Множитель доли от продажи, которую забирает аукцион. 0 — без комиссии."),
|
||||
@@ -58,7 +58,7 @@ T = {
|
||||
"Rate.Drop.Item.Rare": ("Droprate für seltene Gegenstände", "Multiplikator dafür, wie oft seltene (blaue) Gegenstände fallen."),
|
||||
"Rate.Drop.Item.Epic": ("Droprate für epische Gegenstände", "Multiplikator dafür, wie oft epische (lila) Gegenstände fallen."),
|
||||
"Rate.Drop.Item.Legendary": ("Droprate für legendäre Gegenstände", "Multiplikator dafür, wie oft legendäre (orange) Gegenstände fallen."),
|
||||
"Rate.Talent": ("Talentpunkte", "Multiplikator für die Talentpunkte, die ein Charakter pro Stufe erhält."),
|
||||
"Rate.Talent": ("Standard-Talentpunkte (AzerothCore)", "Gilt nur für Standardtalente von AzerothCore. CoA-Klassen- und Spezialisierungspunkte stammen aus Clientdaten und ignorieren diesen Multiplikator."),
|
||||
"Rate.Skill.Discovery": ("Chance auf Rezeptentdeckung", "Multiplikator für die Chance, beim Herstellen ein neues Rezept zu entdecken."),
|
||||
"Rate.RepairCost": ("Reparaturkosten", "Multiplikator für die Kosten der Ausrüstungsreparatur. 0 macht Reparaturen kostenlos."),
|
||||
"Rate.Auction.Cut": ("Auktionshaus-Gebühr", "Multiplikator für den Anteil eines Verkaufs, den das Auktionshaus behält. 0 bedeutet keine Gebühr."),
|
||||
@@ -80,7 +80,7 @@ T = {
|
||||
"Rate.Drop.Item.Rare": ("Taux de butin des objets rares", "Multiplicateur de la fréquence de butin des objets rares (bleus)."),
|
||||
"Rate.Drop.Item.Epic": ("Taux de butin des objets épiques", "Multiplicateur de la fréquence de butin des objets épiques (violets)."),
|
||||
"Rate.Drop.Item.Legendary": ("Taux de butin des objets légendaires", "Multiplicateur de la fréquence de butin des objets légendaires (orange)."),
|
||||
"Rate.Talent": ("Points de talent", "Multiplicateur des points de talent gagnés par niveau."),
|
||||
"Rate.Talent": ("Points de talent standards (AzerothCore)", "Affecte uniquement les talents standards AzerothCore. Les points de classe et de spécialisation CoA proviennent des données du client et ignorent ce multiplicateur."),
|
||||
"Rate.Skill.Discovery": ("Chance de découvrir des recettes", "Multiplicateur de la chance de découvrir une nouvelle recette en fabriquant."),
|
||||
"Rate.RepairCost": ("Coût des réparations", "Multiplicateur du coût de réparation de l'équipement. 0 rend les réparations gratuites."),
|
||||
"Rate.Auction.Cut": ("Commission de l'hôtel des ventes", "Multiplicateur de la part d'une vente que garde l'hôtel des ventes. 0 signifie aucune commission."),
|
||||
@@ -102,7 +102,7 @@ T = {
|
||||
"Rate.Drop.Item.Rare": ("Probabilidad de botín de objetos raros", "Multiplicador de la frecuencia con que caen objetos raros (azules)."),
|
||||
"Rate.Drop.Item.Epic": ("Probabilidad de botín de objetos épicos", "Multiplicador de la frecuencia con que caen objetos épicos (morados)."),
|
||||
"Rate.Drop.Item.Legendary": ("Probabilidad de botín de objetos legendarios", "Multiplicador de la frecuencia con que caen objetos legendarios (naranjas)."),
|
||||
"Rate.Talent": ("Puntos de talento", "Multiplicador de los puntos de talento que gana un personaje por nivel."),
|
||||
"Rate.Talent": ("Puntos de talento estándar (AzerothCore)", "Solo afecta a los talentos estándar de AzerothCore. Los puntos de clase y especialización de CoA proceden de los datos del cliente e ignoran este multiplicador."),
|
||||
"Rate.Skill.Discovery": ("Probabilidad de descubrir recetas", "Multiplicador de la probabilidad de descubrir una receta nueva al fabricar."),
|
||||
"Rate.RepairCost": ("Coste de reparación", "Multiplicador del coste de reparar el equipo. 0 hace las reparaciones gratuitas."),
|
||||
"Rate.Auction.Cut": ("Comisión de la casa de subastas", "Multiplicador de la parte de una venta que se queda la casa de subastas. 0 significa sin comisión."),
|
||||
|
||||
@@ -16,7 +16,7 @@ R = [
|
||||
("Rate.Drop.Money", "float", "rates", "Gold drop rate", "Multiplier for gold dropped by monsters.", 1, 0, 20, "x", False, "world", False),
|
||||
("Rate.Honor", "float", "rates", "Honor gain", "Multiplier for honor points earned.", 1, 0, 20, "x", False, "world", False),
|
||||
("Rate.Reputation.Gain", "float", "rates", "Reputation gain", "Multiplier for reputation earned.", 1, 0, 20, "x", False, "world", False),
|
||||
("Rate.Rest.InGame", "float", "rates", "Rested bonus while playing", "Speed at which rested experience is used up while you play.", 1, 0, 10, "x", True, "world", False),
|
||||
("Rate.Rest.InGame", "float", "rates", "Rested bonus while playing", "Speed at which rested experience accumulates while online in a rest area.", 1, 0, 10, "x", True, "world", False),
|
||||
("Rate.Creature.Normal.Damage", "float", "advanced", "Monster damage", "Multiplier for damage dealt by normal monsters. Changes difficulty a lot.", 1, 0.1, 10, "x", True, "world", True),
|
||||
("Rate.Creature.Normal.HP", "float", "advanced", "Monster health", "Multiplier for health of normal monsters. Changes difficulty a lot.", 1, 0.1, 10, "x", True, "world", True),
|
||||
("PlayerLimit", "int", "world", "Maximum players", "How many players can be online at once. 0 means no limit.", 0, 0, 10000, None, False, "world", False),
|
||||
@@ -50,7 +50,7 @@ R = [
|
||||
("Rate.Drop.Item.Rare", "float", "rates", "Rare item drop rate", "Multiplier for how often rare (blue) items drop.", 1, 0, 20, "x", False, "world", False),
|
||||
("Rate.Drop.Item.Epic", "float", "rates", "Epic item drop rate", "Multiplier for how often epic (purple) items drop.", 1, 0, 20, "x", False, "world", False),
|
||||
("Rate.Drop.Item.Legendary", "float", "rates", "Legendary item drop rate", "Multiplier for how often legendary (orange) items drop.", 1, 0, 20, "x", False, "world", False),
|
||||
("Rate.Talent", "float", "rates", "Talent points", "Multiplier for the talent points a character earns per level.", 1, 0, 10, "x", True, "world", False),
|
||||
("Rate.Talent", "float", "rates", "Standard talent points (AzerothCore)", "Only affects standard AzerothCore talents. CoA class and specialization points come from client data and ignore this multiplier.", 1, 0, 10, "x", True, "world", False),
|
||||
("Rate.Skill.Discovery", "float", "rates", "Recipe discovery chance", "Multiplier for the chance to discover a new recipe while crafting.", 1, 0, 20, "x", True, "world", False),
|
||||
("Rate.RepairCost", "float", "rates", "Repair cost", "Multiplier for what repairing equipment costs. 0 makes repairs free.", 1, 0, 10, "x", False, "world", False),
|
||||
("Rate.Auction.Cut", "float", "rates", "Auction house cut", "Multiplier for the share of a sale the auction house keeps. 0 means no cut.", 1, 0, 10, "x", True, "world", False),
|
||||
|
||||
Reference in new issue
Block a user