preview 0.9.10: bare modular core for new servers, modular copy of classic servers

- A new server goes on to the preview core without compiling any module
  (recommended modules are declined once; modules come from the Modules page).
- Choosing a server with the classic core makes a modular copy next to it:
  every acore_* database (read from the running database, or from a private
  copy of the stopped data files opened by the new server's own mysqld),
  module configs and realm profiles, ports and console login. The old server
  is only read. The copy then takes the preview core with the modules the old
  server had switched on.
- Installs and the preview update run on unused ports, so a server running on
  the shipped ports (the old one, or any other) no longer stops them; the
  schema check after "Preparing database" used the shipped port before.
- Dungeon Clear is part of SQUID's Playerbots: no card of its own (it said
  "Always on" while SQUID was off), switched with SQUID, settings on the SQUID card.

E2E (examples/preview_bare_and_copy_e2e, disposable servers, another server
running on the shipped ports): fresh install committed with no plugins and a
clean boot report; copy of a stopped and of a running classic server: data
identical, old server's files byte-for-byte unchanged, Content Scaling and
Companions built on the copy, preview core committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ДмитрийandClaude Opus 5.5 committed 2026-11-06 23:49:03 +05:00
1 parent 3866e219ce
commit 9f48cfc8d5
22 files changed
+799 -56

No files matched your search

Generated
+3 -3
View File
@@ -586,7 +586,7 @@ dependencies = [
[[package]]
name = "coa-core"
version = "0.9.9"
version = "0.9.10"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -634,7 +634,7 @@ dependencies = [
[[package]]
name = "coa-release"
version = "0.9.9"
version = "0.9.10"
dependencies = [
"chrono",
"coa-core",
@@ -772,7 +772,7 @@ dependencies = [
[[package]]
name = "csm-modules-preview"
version = "0.9.9"
version = "0.9.10"
dependencies = [
"coa-control-proto",
"coa-core",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/coa-core", "crates/coa-release", "crates/coa-registry-proto",
exclude = ["services/coa-registry", "services/coa-registry-e2e", "services/coa-coordinator", "services/coa-relay"]
[workspace.package]
version = "0.9.9"
version = "0.9.10"
edition = "2021"
license = "AGPL-3.0-only"
@@ -0,0 +1,8 @@
---
area: manager
type: changed
audience: admins
title: Dungeon Clear is shown and switched as part of SQUID's Playerbots
---
Dungeon Clear no longer has a card of its own, which said "Always on" while SQUID was off. It goes on
and off together with SQUID's Playerbots, and its settings open from the SQUID card.
@@ -0,0 +1,10 @@
---
area: manager
type: changed
audience: admins
title: Modules Preview installs the bare modular core and copies classic servers instead of converting them
---
A new server installed by the Modules Preview goes straight on to the modular core and compiles no
module; modules are added later from the Modules page. Choosing an existing server with the classic
core no longer adds it as it is: a modular server is installed next to it and its accounts, characters,
world, bots and module settings are copied into it. The old server is only read and keeps working.
@@ -0,0 +1,177 @@
//! 0.9.10 install paths of the Modules Preview, end to end on DISPOSABLE servers under <work>:
//!
//! 1. fresh: a new server goes on to the preview core with the bare core (no module is compiled).
//! 2. copy: a classic server (stopped) gets a modular copy next to it: accounts, characters, the world and
//! module settings arrive, the old server's files are byte-for-byte unchanged, the copy takes the
//! preview core with the modules the old server had switched on.
//! 3. copy-running: the same copy step from a classic server whose database is running.
//!
//! usage: preview_bare_and_copy_e2e <empty work folder> [fresh|copy|all]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use coa_core::install::{install_base, Params, Source};
use coa_core::registry::Registry;
use coa_core::update::{self, RepackEnv};
const KEY: &str = coa_core::signing::EMBEDDED_PUBLIC_KEY;
type R<T> = Result<T, Box<dyn std::error::Error>>;
fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
}
fn sql(root: &Path, q: &str) -> String {
coa_core::backup::with_database(root, |db| db.query(q)).unwrap_or_else(|e| panic!("{q}: {e}"))
}
fn install(root: &Path, reg: &Registry) -> R<()> {
let image = coa_core::channels::stable_source()?;
let t = std::time::Instant::now();
let done = install_base(
&Params { source: Source::Installation(Box::new(image)), dest: root.into(), trusted_key: KEY, registry: reg, cancel: Default::default() },
&|s| eprintln!(" {:>3}% {}", s.percent, s.step),
)?;
println!("[{}] installed {} in {:?}", root.display(), done.version, t.elapsed());
Ok(())
}
fn set_free_ports(root: &Path) -> R<()> {
let repack = root.join("Settings/repack.json");
let mut cfg: serde_json::Value = serde_json::from_slice(&std::fs::read(&repack)?)?;
for k in ["mysqlPort", "authPort", "worldPort", "raPort"] {
cfg[k] = free_port().into();
}
std::fs::write(&repack, serde_json::to_vec_pretty(&cfg)?)?;
Ok(())
}
/// The same steps as the Manager's install_preview_core.
fn preview_core(root: &Path) -> R<update::Outcome> {
let meta_dir = coa_core::registry::metadata_dir_for(root)?;
let (_, meta) = coa_core::registry::MetaDir::open(&meta_dir)?;
let source = coa_core::update_route::select(&meta, coa_core::channels::preview_update_source()?, KEY)?.source;
let env = RepackEnv { root, meta_dir: &meta_dir };
let t = std::time::Instant::now();
let out = coa_core::install::with_scratch_ports(root, || update::apply(
&update::Params { root, meta_dir: &meta_dir, source, trusted_key: KEY, cancel: Default::default(), resolutions: Default::default(), env: &env, fail_after_ops: None },
&|phase, percent| println!(" {percent}% {phase}"),
));
let _ = coa_core::driver::run(root, coa_core::driver::Verb::StopAll);
let out = out?;
println!("[{}] preview core: {:?} in {:?}; modules {:?}", root.display(), out.txn.state, t.elapsed(), out.modules);
assert_eq!(out.txn.state, update::State::Committed);
assert!(coa_core::provenance::realm_host_fingerprint(root).is_some(), "the plugin host is installed");
Ok(out)
}
/// sha256 of every file under the old server, except the launcher's own run state.
fn tree(root: &Path) -> BTreeMap<String, String> {
fn walk(base: &Path, dir: &Path, out: &mut BTreeMap<String, String>) {
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let p = e.path();
if p.is_dir() {
walk(base, &p, out);
} else {
let rel = p.strip_prefix(base).unwrap().to_string_lossy().replace('\\', "/");
out.insert(rel, coa_core::fsx::sha256_file(&p).unwrap_or_else(|e| format!("unreadable: {e}")));
}
}
}
let mut out = BTreeMap::new();
walk(root, root, &mut out);
out
}
fn fresh(work: &Path, reg: &Registry) -> R<()> {
let root = work.join("fresh-fixture");
install(&root, reg)?; // shipped ports, as in the Manager: another server may be running on them
coa_core::rebuild::decline_recommended(&root)?;
let out = preview_core(&root)?;
assert!(out.modules.is_empty(), "a new server compiles nothing: {:?}", out.modules);
let registry = coa_core::plugins::load_registry(&root)?;
assert!(registry.plugins.values().all(|e| !e.installed), "no plugin installed: {:?}", registry.plugins.keys());
let boot: serde_json::Value = serde_json::from_slice(&std::fs::read(root.join("Core/plugins/plugin-boot-report.json"))?)?;
println!("boot report: {boot}");
assert_eq!(boot["anyFaulted"], serde_json::json!(false));
println!("FRESH PASS");
Ok(())
}
fn copy(work: &Path, reg: &Registry) -> R<()> {
// A classic server with data of its own.
let old = work.join("classic-fixture");
install(&old, reg)?;
set_free_ports(&old)?; // the test runs this "old" server; it must not meet a real one on the shipped ports
sql(&old, "INSERT INTO acore_auth.account (username, salt, verifier, email, reg_mail) \
VALUES ('E2ECOPY', UNHEX(REPEAT('11',32)), UNHEX(REPEAT('22',32)), '', '')");
let account = sql(&old, "SELECT id FROM acore_auth.account WHERE username='E2ECOPY'").trim().to_string();
sql(&old, &format!("SET SESSION sql_mode=''; INSERT INTO acore_characters.characters (guid, account, name, race, class, gender, level, money) \
VALUES (900001, {account}, 'Copytest', 1, 1, 0, 42, 123456)"));
let conf = old.join("Core/configs/modules/mod-coa-content-scaling.conf");
let conf_text = std::fs::read_to_string(&conf).unwrap_or_default() + "\n# e2e owner edit\n";
std::fs::write(&conf, &conf_text)?;
let fingerprint = |root: &Path| sql(root, "SELECT CONCAT((SELECT COUNT(*) FROM acore_auth.account),'|',(SELECT COUNT(*) FROM acore_characters.characters),'|',\
(SELECT CONCAT(name,':',level,':',money) FROM acore_characters.characters WHERE guid=900001))").trim().to_string();
let expected = fingerprint(&old);
let _ = coa_core::driver::run(&old, coa_core::driver::Verb::StopAll);
println!("old server data: {expected}");
assert!(coa_core::modular_copy::is_classic(&old));
// 2. copy of the stopped server, then the preview core on the copy.
let before = tree(&old);
let new = work.join("copy-fixture");
install(&new, reg)?;
let report = coa_core::modular_copy::copy_data(&old, &new, &|w| println!(" copy: {w}"))?;
println!("copy report: {report:?}");
assert_eq!(report.read_from, "copy of the stopped database");
assert_eq!(tree(&old), before, "the old server's files changed");
let observed = coa_core::process::observe(&new, &coa_core::layout::read_ports(&new));
assert_eq!(observed.mysql.state, coa_core::process::ServiceState::Stopped, "a database of the copy is still running: {:?}", observed.mysql);
assert!(!coa_core::registry::metadata_dir_for(&new)?.join("import").exists(), "the private copy of the old data is removed");
assert_eq!(std::fs::read_to_string(new.join("Core/configs/modules/mod-coa-content-scaling.conf"))?, conf_text);
let out = preview_core(&new)?;
println!("modules on the copy: {:?}", out.modules);
let got = fingerprint(&new);
let _ = coa_core::driver::run(&new, coa_core::driver::Verb::StopAll);
assert_eq!(got, expected, "data of the copy after the preview core");
assert_eq!(tree(&old), before, "the old server's files changed");
println!("COPY (stopped) PASS");
// 3. copy while the old database runs: read from it, change nothing.
let started = coa_core::driver::run(&old, coa_core::driver::Verb::StartMysql)?;
assert!(started.ok, "{}", started.output);
let data_before: BTreeMap<String, String> = tree(&old).into_iter().filter(|(k, _)| !k.starts_with("mysql/") && !k.starts_with(".state/") && !k.starts_with("Core/configs/")).collect();
let rows_before = fingerprint(&old);
let new2 = work.join("copy-running-fixture");
install(&new2, reg)?;
let report = coa_core::modular_copy::copy_data(&old, &new2, &|w| println!(" copy: {w}"));
let rows_after = fingerprint(&old);
let _ = coa_core::driver::run(&old, coa_core::driver::Verb::StopAll);
let report = report?;
assert_eq!(report.read_from, "running database");
assert_eq!(rows_after, rows_before);
let data_after: BTreeMap<String, String> = tree(&old).into_iter().filter(|(k, _)| !k.starts_with("mysql/") && !k.starts_with(".state/") && !k.starts_with("Core/configs/")).collect();
assert_eq!(data_after, data_before, "files outside the database changed");
assert_eq!(fingerprint(&new2), expected);
let _ = coa_core::driver::run(&new2, coa_core::driver::Verb::StopAll);
println!("COPY (running) PASS");
Ok(())
}
fn main() -> R<()> {
let a: Vec<String> = std::env::args().skip(1).collect();
let work = PathBuf::from(a.first().expect("usage: preview_bare_and_copy_e2e <empty work folder> [fresh|copy|all]"));
let mode = a.get(1).map(String::as_str).unwrap_or("all");
assert!(!work.exists() || std::fs::read_dir(&work)?.next().is_none(), "work folder must be new or empty");
std::fs::create_dir_all(&work)?;
let reg = Registry::at(work.join("installs.json"));
if mode == "fresh" || mode == "all" {
fresh(&work, &reg)?;
}
if mode == "copy" || mode == "all" {
copy(&work, &reg)?;
}
println!("ALL PASS ({mode})");
Ok(())
}
+11
View File
@@ -337,6 +337,17 @@ impl Db {
.map(|o| o.status.success()).unwrap_or(false)
}
/// Ask the server to shut down cleanly (`mysqladmin shutdown`).
pub fn shutdown_server(&self) -> Result<()> {
let mut c = self.command("mysqladmin.exe");
c.arg("shutdown").arg("--connect-timeout=5");
let out = bounded_client_output(&mut c, b"", Instant::now() + Duration::from_secs(60))?;
if !out.status.success() {
return Err(self.fail(&out.stderr));
}
Ok(())
}
pub(crate) fn with_read_deadline(mut self, deadline: Instant) -> Self {
self.read_deadline = Some(deadline);
self
+13 -10
View File
@@ -210,7 +210,7 @@ pub(crate) fn free_port() -> Result<u16> {
/// Run `f` with the staging copy listening on unused ports, so an installation never collides with a server (or any
/// other program) already using the shipped ports. The shipped ports are put back afterwards.
pub(crate) fn with_scratch_ports<T>(root: &Path, f: impl FnOnce() -> Result<T>) -> Result<T> {
pub fn with_scratch_ports<T>(root: &Path, f: impl FnOnce() -> Result<T>) -> Result<T> {
const KEYS: [&str; 4] = ["mysqlPort", "authPort", "worldPort", "raPort"];
let path = root.join("Settings/repack.json");
let original: serde_json::Value = fsx::read_json(&path)?;
@@ -378,15 +378,18 @@ pub fn install_base(p: &Params, report: &dyn Fn(Step)) -> Result<Installed> {
})?;
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(())
// On unused ports too: another server running on the shipped ports must not stop the installation.
with_scratch_ports(&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(())
})
})?;
// 4. Commit: the only moment the destination changes. The marker stays until everything is done, so an
// unfinished install can always be recognised as ours (and cleaned up), wherever it stopped.
+1
View File
@@ -43,6 +43,7 @@ pub mod logging;
pub mod server_logging;
pub mod manifest;
pub mod migrations;
pub mod modular_copy;
pub mod modules;
pub mod module_sources;
pub mod msvc_setup;
+336
View File
@@ -0,0 +1,336 @@
//! A modular copy of a classic server, next to it.
//!
//! A server with the classic core (every module compiled into `worldserver.exe`) is never converted in place.
//! The Modules Preview installs a new server beside it and copies the old one's data into it: every game
//! database (accounts, characters, world, bots) and the module settings. The old server is only read:
//!
//! * if its database is running, the schemas are dumped from it (consistent snapshots, nothing is written);
//! * if it is stopped, its data files are copied to a private folder and read by a temporary database
//! process of the NEW server; nothing of the old server is started or executed.
//!
//! The new server then takes the preview core like any other server (the caller applies the update).
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use serde::Serialize;
use crate::db::{Account, Db};
use crate::error::{Error, Result};
use crate::fsx;
use crate::process::ServiceState;
/// Whether `root` runs the classic core: a Windows repack whose `worldserver.exe` carries no plugin-host
/// fingerprint.
pub fn is_classic(root: &Path) -> bool {
!crate::docker::is_docker(root)
&& root.join("Core/worldserver.exe").is_file()
&& crate::provenance::realm_host_fingerprint(root).is_none()
}
#[derive(Debug, Clone, Serialize)]
pub struct CopyReport {
pub schemas: Vec<String>,
pub accounts: u64,
pub characters: u64,
pub config_files: Vec<String>,
/// How the old data was read: "running database" or "copy of the stopped database".
pub read_from: String,
}
/// Game schemas of a CoA server: `acore_*` (auth, characters, world, their Wildcard/Reborn variants, bots).
fn game_schemas(db: &Db) -> Result<Vec<String>> {
let mut out: Vec<String> = db
.query("SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME LIKE 'acore\\_%' ORDER BY SCHEMA_NAME;")?
.lines()
.map(|l| l.trim().to_string())
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'))
// work schemas a restore leaves behind are not game data
.filter(|s| !s.contains("_restore_") && !s.contains("_before_restore_"))
.collect();
out.dedup();
if !out.iter().any(|s| s == "acore_auth") || !out.iter().any(|s| s == "acore_characters") {
return Err(Error::Invalid("The old server's database has no accounts or characters schema.".into()));
}
Ok(out)
}
fn count(db: &Db, sql: &str) -> u64 {
db.query(sql).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0)
}
/// A database process of the new server reading a private copy of the old server's stopped data files.
struct TemporaryDatabase {
child: Child,
dir: PathBuf,
db: Option<Db>,
stopped: bool,
}
impl TemporaryDatabase {
/// Shut it down cleanly, force it if it does not go, and remove the private copy. Fails if the process is
/// still there afterwards, so a leftover database never holds the new server's files.
fn stop(&mut self) -> Result<()> {
if self.stopped {
return Ok(());
}
if let Some(db) = &self.db {
let _ = db.shutdown_server();
}
let deadline = Instant::now() + Duration::from_secs(90);
loop {
match self.child.try_wait()? {
Some(_) => break,
None if Instant::now() > deadline => {
tracing::warn!(pid = self.child.id(), "temporary database did not shut down; terminating it");
self.child.kill()?;
self.child.wait()?;
break;
}
None => std::thread::sleep(Duration::from_millis(250)),
}
}
self.stopped = true;
fs::remove_dir_all(&self.dir)?;
Ok(())
}
}
impl Drop for TemporaryDatabase {
fn drop(&mut self) {
if !self.stopped {
if let Err(e) = self.stop() {
tracing::warn!(error = %e, pid = self.child.id(), "temporary database left behind");
}
}
}
}
fn copy_tree(from: &Path, to: &Path) -> Result<u64> {
let mut bytes = 0;
fs::create_dir_all(to)?;
for entry in fs::read_dir(from)? {
let entry = entry?;
let md = fs::symlink_metadata(entry.path())?;
if md.file_type().is_symlink() {
return Err(Error::Invalid(format!("{} is a link; the database folder is not copied.", entry.path().display())));
}
let target = to.join(entry.file_name());
if md.is_dir() {
bytes += copy_tree(&entry.path(), &target)?;
} else {
fs::copy(entry.path(), &target)?;
bytes += md.len();
}
}
Ok(bytes)
}
fn dir_size(dir: &Path) -> u64 {
fs::read_dir(dir).map(|rd| rd.flatten().map(|e| {
let p = e.path();
if p.is_dir() { dir_size(&p) } else { e.metadata().map(|m| m.len()).unwrap_or(0) }
}).sum()).unwrap_or(0)
}
fn start_temporary(old: &Path, new: &Path, work: &Path, progress: &dyn Fn(&str)) -> Result<(TemporaryDatabase, Db)> {
let source = old.join("mysql/data");
if !source.join("mysql").is_dir() || !source.join("ibdata1").is_file() {
return Err(Error::Invalid("The old server's database folder is incomplete.".into()));
}
let dir = work.join("old-database");
let _ = fs::remove_dir_all(&dir);
fsx::require_space(work, dir_size(&source))?;
progress("Copying the old server's database files");
let data = dir.join("data");
copy_tree(&source, &data)?;
let (root_password, _) = crate::db::credentials(old)?;
let bin = new.join("mysql/bin");
let port = crate::install::free_port()?;
let child = Command::new(bin.join("mysqld.exe"))
.arg("--no-defaults")
.arg(format!("--basedir={}", new.join("mysql").display()))
.arg(format!("--datadir={}", data.display()))
.arg(format!("--port={port}"))
.args(["--bind-address=127.0.0.1", "--mysqlx=0", "--skip-log-bin", "--character-set-server=utf8mb4",
"--collation-server=utf8mb4_unicode_ci", "--max_allowed_packet=128M"])
.arg(format!("--log-error={}", dir.join("mysql-error.log").display()))
.arg(format!("--pid-file={}", dir.join("mysql.pid").display()))
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags_hidden()
.spawn()?;
let db = Db::with_tools(bin, port, "root", &root_password, crate::realms::Mode::Coa);
let mut temp = TemporaryDatabase { child, dir, db: Some(db.clone()), stopped: false };
progress("Opening the copy of the old database");
let deadline = Instant::now() + Duration::from_secs(300);
while !db.ping() {
if let Ok(Some(status)) = temp.child.try_wait() {
let log = fs::read_to_string(temp.dir.join("mysql-error.log")).unwrap_or_default();
let tail: Vec<&str> = log.lines().rev().take(8).collect();
return Err(Error::Invalid(format!(
"The copy of the old database did not open ({status}). {}",
tail.into_iter().rev().collect::<Vec<_>>().join(" ")
)));
}
if Instant::now() > deadline {
return Err(Error::Invalid("The copy of the old database did not open within 5 minutes.".into()));
}
std::thread::sleep(Duration::from_millis(300));
}
Ok((temp, db))
}
trait HiddenWindow {
fn creation_flags_hidden(&mut self) -> &mut Self;
}
impl HiddenWindow for Command {
fn creation_flags_hidden(&mut self) -> &mut Self {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
self.creation_flags(0x0800_0000);
}
self
}
}
/// Settings the owner may have changed that the new core can take as they are: the module configs
/// (`Core/configs/modules/*.conf`; the shipped `.dist` files belong to the new core) and the realm profiles.
/// `worldserver.conf` and `authserver.conf` are rendered by the launcher from the new server's own
/// templates at every start, so they are not copied.
fn settings_files(old: &Path) -> Vec<String> {
crate::backup::config_files(old)
.into_iter()
.filter(|f| {
(f.starts_with("Core/configs/modules/") && f.ends_with(".conf") && !f.ends_with("/mod_ascension_compat.conf"))
|| f.starts_with("Settings/realm-profile")
})
.collect()
}
/// Copy the old server's data into `new`, a server just installed and not yet started by anyone.
pub fn copy_data(old: &Path, new: &Path, progress: &dyn Fn(&str)) -> Result<CopyReport> {
if !is_classic(old) {
return Err(Error::Invalid("Only a server with the classic core is copied.".into()));
}
if fsx::canonicalize_lenient(old)? == fsx::canonicalize_lenient(new)? {
return Err(Error::Invalid("The new server must be in another folder.".into()));
}
let work = crate::registry::metadata_dir_for(new)?.join("import");
let _ = fs::remove_dir_all(&work);
fs::create_dir_all(&work)?;
// 1. Read the old databases.
let observed = crate::process::observe(old, &crate::layout::read_ports(old));
let running = observed.mysql.state == ServiceState::Running
&& Db::from_repack(old, Account::Admin).map(|db| db.ping()).unwrap_or(false);
let mut temporary: Option<TemporaryDatabase> = None;
let (source, read_from) = if running {
(Db::from_repack(old, Account::Admin)?.for_realm(crate::realms::Mode::Coa), "running database")
} else {
let (temp, db) = start_temporary(old, new, &work, progress)?;
temporary = Some(temp);
(db, "copy of the stopped database")
};
let schemas = game_schemas(&source)?;
let accounts = count(&source, "SELECT COUNT(*) FROM acore_auth.account;");
let characters = count(&source, "SELECT COUNT(*) FROM acore_characters.characters;");
let mut dumps = Vec::new();
for schema in &schemas {
progress(&format!("Copying {schema}"));
let file = work.join(format!("{schema}.sql.zst"));
source.dump_to(schema, &file)?;
dumps.push((schema.clone(), file));
}
// The private copy of the old data files is not needed any more; its database must be gone before the new
// server's own database starts.
if let Some(mut temp) = temporary.take() {
progress("Closing the copy of the old database");
temp.stop()?;
}
// 2. Put them into the new server, on ports nobody uses, so a running old server is not disturbed.
crate::install::with_scratch_ports(new, || {
crate::backup::with_database(new, |db| {
let db = db.clone().for_realm(crate::realms::Mode::Coa);
for (schema, file) in &dumps {
progress(&format!("Importing {schema}"));
db.query(&format!(
"DROP DATABASE IF EXISTS `{schema}`; CREATE DATABASE `{schema}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
))?;
db.import_from(schema, file)?;
}
let (a, c) = (
count(&db, "SELECT COUNT(*) FROM acore_auth.account;"),
count(&db, "SELECT COUNT(*) FROM acore_characters.characters;"),
);
if (a, c) != (accounts, characters) {
return Err(Error::Invalid(format!(
"The copy is incomplete: {a} of {accounts} accounts and {c} of {characters} characters arrived."
)));
}
Ok(())
})
})?;
// 3. Settings: module configs and realm profiles; ports and console login as the old server had them
// (the console account lives in the copied accounts database).
progress("Copying settings");
let files = settings_files(old);
for rel in &files {
let from = fsx::ensure_within(old, &fsx::safe_join(old, rel)?)?;
let to = fsx::ensure_within(new, &fsx::safe_join(new, rel)?)?;
fsx::atomic_write(&to, &fs::read(from)?)?;
}
let old_repack: serde_json::Value = fsx::read_json(&old.join("Settings/repack.json"))?;
let mut repack: serde_json::Value = fsx::read_json(&new.join("Settings/repack.json"))?;
if let (Some(dst), Some(src)) = (repack.as_object_mut(), old_repack.as_object()) {
for (k, v) in src {
dst.insert(k.clone(), v.clone());
}
}
fsx::atomic_write_json(&new.join("Settings/repack.json"), &repack)?;
let _ = fs::remove_dir_all(&work);
Ok(CopyReport { schemas, accounts, characters, config_files: files, read_from: read_from.into() })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_owner_settings_are_copied() {
let dir = tempfile::tempdir().unwrap();
let old = dir.path();
for rel in [
"Core/configs/worldserver.conf",
"Core/configs/worldserver.conf.dist",
"Core/configs/modules/mod_x.conf",
"Core/configs/modules/mod_x.conf.dist",
"Core/configs/modules/mod_ascension_compat.conf",
"Settings/repack.json",
"Settings/worldserver.conf.template",
"Settings/realm-profile.json",
] {
let p = old.join(rel);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, b"x").unwrap();
}
let mut got = settings_files(old);
got.sort();
assert_eq!(got, vec![
"Core/configs/modules/mod_x.conf".to_string(),
"Settings/realm-profile.json".to_string(),
]);
}
#[test]
fn a_folder_without_a_core_is_not_classic() {
let dir = tempfile::tempdir().unwrap();
assert!(!is_classic(dir.path()));
}
}
+18 -3
View File
@@ -711,6 +711,17 @@ pub fn ensure_bot_exclusivity(root: &Path) -> Result<()> {
Ok(())
}
/// Dungeon Clear is part of SQUID's Playerbots (the plugin core builds it into the SQUID DLL): it goes on
/// with SQUID, and off with it, wherever its config exists.
fn switch_on_dungeon_clear_with_squid(cat: &[Entry], root: &Path, meta: &Path) {
if let Some(dc) = cat.iter().find(|entry| entry.id == "dungeon-clear") {
let name = conf_name(root, dc);
if dir(root).join(&name).exists() || dir(root).join(format!("{name}.dist")).exists() {
let _ = set_enabled_in(cat, root, meta, "dungeon-clear", true);
}
}
}
fn set_enabled_in(cat: &[Entry], root: &Path, meta: &Path, id: &str, on: bool) -> Result<()> {
match entry_in(cat, id) {
Ok(e) => {
@@ -770,6 +781,7 @@ fn set_enabled_in(cat: &[Entry], root: &Path, meta: &Path, id: &str, on: bool) -
}
if id == "playerbots" && on {
let _ = crate::plugins::record_squid_migrations_applied(root);
switch_on_dungeon_clear_with_squid(cat, root, meta);
}
tracing::info!(module = id, on, "module switched");
@@ -806,6 +818,9 @@ fn set_enabled_in(cat: &[Entry], root: &Path, meta: &Path, id: &str, on: bool) -
let _ = set_enabled_in(cat, root, meta, "dungeon-clear", false);
}
}
if id == "playerbots" && on {
switch_on_dungeon_clear_with_squid(cat, root, meta);
}
return Ok(());
}
}
@@ -1255,7 +1270,7 @@ mod tests {
}
#[test]
fn dungeon_clear_requires_squid_and_stays_off_after_squid_is_reenabled() {
fn dungeon_clear_requires_squid_and_follows_it() {
let d = tempfile::tempdir().unwrap();
let root = d.path();
fs::create_dir_all(dir(root)).unwrap();
@@ -1266,13 +1281,13 @@ mod tests {
let cat = catalog();
assert!(set_enabled_in(&cat, root, root, "dungeon-clear", true).is_err());
assert!(!list_in(&cat, root).iter().find(|entry| entry.id == "dungeon-clear").unwrap().switchable);
// part of SQUID: on with it, off with it, on again with it
set_enabled_in(&cat, root, root, "playerbots", true).unwrap();
set_enabled_in(&cat, root, root, "dungeon-clear", true).unwrap();
assert!(read(&dungeon).unwrap().get("DungeonClear.Enable").map(truthy).unwrap());
set_enabled_in(&cat, root, root, "playerbots", false).unwrap();
assert!(!read(&dungeon).unwrap().get("DungeonClear.Enable").map(truthy).unwrap());
set_enabled_in(&cat, root, root, "playerbots", true).unwrap();
assert!(!read(&dungeon).unwrap().get("DungeonClear.Enable").map(truthy).unwrap());
assert!(read(&dungeon).unwrap().get("DungeonClear.Enable").map(truthy).unwrap());
}
#[test]
+42
View File
@@ -259,9 +259,32 @@ fn split_repo(repo: &str) -> std::result::Result<(&str, &str), String> {
.ok_or_else(|| format!("invalid repository '{repo}'"))
}
/// Mark a server that starts with the bare core: the modules the core recommends are not installed on it
/// (the owner adds modules from the Modules page). Taken into account the first time a plugin-host core
/// arrives; after that every recommended module counts as dealt with.
pub fn decline_recommended(realm_root: &Path) -> Result<()> {
let body = serde_json::json!({ "schema": 1, "handled": load_handled(realm_root), "declineAll": true });
fs::create_dir_all(plugins::meta_dir(realm_root))?;
crate::fsx::atomic_write(&handled_path(realm_root), serde_json::to_string_pretty(&body)?.as_bytes())
}
fn declined_all(realm_root: &Path) -> bool {
fs::read(handled_path(realm_root)).ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|v| v.get("declineAll")?.as_bool())
.unwrap_or(false)
}
fn install_recommended(realm_root: &Path, progress: &dyn Fn(&str, &str)) -> Vec<ModuleRebuild> {
let Some(rec) = recommended(realm_root) else { return Vec::new() };
let mut handled = load_handled(realm_root);
if declined_all(realm_root) {
for m in &rec.modules {
handled.entry(m.id.clone()).or_insert_with(|| "not installed: new server with the bare core".into());
}
save_handled(realm_root, &handled); // drops the mark: it applies to this first core only
return Vec::new();
}
let mut out = Vec::new();
for m in &rec.modules {
let registry = plugins::load_registry(realm_root).unwrap_or_default();
@@ -455,6 +478,25 @@ mod tests {
assert!(!handled.contains_key("companions"));
}
#[test]
fn a_new_server_with_the_bare_core_installs_no_recommended_module() {
let dir = tempfile::tempdir().unwrap();
let server = dir.path();
fs::create_dir_all(server.join("Core/configs/modules")).unwrap();
fs::create_dir_all(server.join("Core/plugins")).unwrap();
fs::write(server.join("Core/worldserver.exe"), image("worldserver", &"c".repeat(64))).unwrap();
decline_recommended(server).unwrap();
// the mark is written before the plugin-host core (and its recommended.json) arrives
fs::write(server.join("Core/plugins/recommended.json"), r#"{"schema":1,"modules":[
{"id":"companions","repo":"not-a-repo","commit":"0000000000000000000000000000000000000000"}]}"#).unwrap();
let out = rebuild_for_current_core(server, &|_, _| panic!("nothing is built")).unwrap();
assert!(out.is_empty(), "{out:?}");
assert!(load_handled(server)["companions"].contains("bare core"));
assert!(!declined_all(server), "the mark applies to the first plugin-host core only");
// a later core change does not bring it back either
assert!(rebuild_for_current_core(server, &|_, _| panic!("nothing is built")).unwrap().is_empty());
}
/// Real run: COA_TEST_RECOMMENDED_REALM is a plugin-host realm with Core/plugins/recommended.json (or
/// plugins/recommended.json); the modules are downloaded from GitHub, built and switched on.
#[test]
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "coa-server-manager",
"version": "0.9.9",
"version": "0.9.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "coa-server-manager",
"version": "0.9.9",
"version": "0.9.10",
"dependencies": {
"@tauri-apps/api": "^2.12.0",
"@tauri-apps/plugin-dialog": "^2.8.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "coa-server-manager",
"private": true,
"version": "0.9.9",
"version": "0.9.10",
"license": "AGPL-3.0-only",
"type": "module",
"scripts": {
+62 -8
View File
@@ -1062,12 +1062,25 @@ async fn install_new(
dest: String,
package: Option<String>,
game_data: Option<String>,
copy_from: Option<String>,
) -> std::result::Result<InstalledServer, UiError> {
let copy_from = copy_from.filter(|p| !p.trim().is_empty()).map(PathBuf::from);
if platform::flavor() == Flavor::Docker {
if copy_from.is_some() {
return Err(Error::Invalid("A modular copy is made of a Windows server only.".into()).into());
}
// The plugin host is Windows-only for now: a Linux server keeps the classic core.
return docker_install::install(app, state, dest, package, game_data)
.await
.map(|summary| InstalledServer { summary, preview_core_error: None, modules_off: Vec::new() });
.map(|summary| InstalledServer { summary, preview_core_error: None, modules_off: Vec::new(), copied: None });
}
if let Some(old) = &copy_from {
if !coa_core::modular_copy::is_classic(old) {
return Err(Error::Invalid("This server already has the modular core; add it as it is.".into()).into());
}
}
if std::fs::read_dir(&dest).is_ok_and(|mut d| d.next().is_some()) {
return Err(Error::Invalid("Choose a new or empty folder for the server: this one already has files.".into()).into());
}
let cancel = Cancel::default();
*state
@@ -1077,14 +1090,15 @@ async fn install_new(
let registry = Registry::at(data_dir().join("installs.json"));
let dest_path = PathBuf::from(dest);
// A package the owner picked by hand is installed as it is; otherwise the new server goes straight on to the
// preview core, so it starts out with modules as plugins instead of the classic core with them built in.
let to_preview = package.as_deref().map_or(true, |p| p.trim().is_empty());
// preview core. A new server starts with the bare core (modules are added later from the Modules page); a
// modular copy of a classic server gets the modules its old server had switched on.
let to_preview = package.as_deref().map_or(true, |p| p.trim().is_empty()) || copy_from.is_some();
let result = tauri::async_runtime::spawn_blocking(move || {
let source = installation_source(package)?;
let done = install::install_base(
&install::Params {
source,
dest: dest_path,
dest: dest_path.clone(),
trusted_key: coa_core::signing::EMBEDDED_PUBLIC_KEY,
registry: &registry,
cancel,
@@ -1093,27 +1107,63 @@ async fn install_new(
let _ = app.emit("install-progress", step);
},
)?;
let root = PathBuf::from(&done.path);
let copied = match &copy_from {
Some(old) => {
let report = coa_core::modular_copy::copy_data(old, &root, &|what| {
let _ = app.emit(
"install-progress",
serde_json::json!({ "step": "Copying the old server's data", "percent": 100, "detail": what }),
);
});
match report {
Ok(r) => Some(r),
Err(e) => {
// Nothing of the old server was changed; take away the half-made copy (this call created it).
let _ = registry.unregister(&done.id);
let _ = coa_core::driver::run(&root, Verb::StopAll);
let _ = std::fs::remove_dir_all(&root);
if let Ok(meta) = metadata_dir_for(&root) {
let _ = std::fs::remove_dir_all(meta);
}
return Err(Error::Invalid(format!("The old server's data could not be copied: {e}")));
}
}
}
None => {
if to_preview {
coa_core::rebuild::decline_recommended(&root)?;
}
None
}
};
let (preview_core_error, modules_off) = match to_preview {
false => (None, Vec::new()),
true => match install_preview_core(&app, &PathBuf::from(&done.path)) {
true => match install_preview_core(&app, &root) {
Ok(off) => (None, off),
Err(e) => (Some(e), Vec::new()),
},
};
Ok::<_, Error>((done, preview_core_error, modules_off))
Ok::<_, Error>((done, preview_core_error, modules_off, copied))
})
.await;
if let Ok(mut c) = state.install_cancel.lock() {
*c = None;
}
let (done, preview_core_error, modules_off) = result.map_err(|e| Error::Invalid(e.to_string()))??;
let (done, preview_core_error, modules_off, copied) = result.map_err(|e| Error::Invalid(e.to_string()))??;
Ok(InstalledServer {
summary: summary(done.id, PathBuf::from(done.path)),
preview_core_error,
modules_off,
copied,
})
}
#[tauri::command]
fn is_classic_server(path: String) -> bool {
coa_core::modular_copy::is_classic(Path::new(&path))
}
#[derive(Serialize)]
struct InstalledServer {
#[serde(flatten)]
@@ -1122,6 +1172,8 @@ struct InstalledServer {
preview_core_error: Option<String>,
/// Modules that did not build for the preview core and were switched off, with the reason.
modules_off: Vec<String>,
/// For a modular copy of a classic server: what was copied.
copied: Option<coa_core::modular_copy::CopyReport>,
}
/// Put the preview core on a server that was just installed from the stable image: the same signed update every
@@ -1160,7 +1212,8 @@ fn install_preview_core(app: &AppHandle, root: &Path) -> std::result::Result<Vec
},
)
};
let outcome = run().map_err(|e| e.to_string())?;
// Unused ports while it runs: the server it was copied from (or any other) may be running on the same ones.
let outcome = coa_core::install::with_scratch_ports(root, run).map_err(|e| e.to_string())?;
if outcome.txn.state != update::State::Committed {
return Err(format!(
"the preview core update stopped in state {:?}; open Updates to finish or roll it back",
@@ -3530,6 +3583,7 @@ pub fn run() {
account_rename,
account_delete,
install_new,
is_classic_server,
cancel_install,
create_account,
check_update,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CSM Modules Preview",
"version": "0.9.9",
"version": "0.9.10",
"identifier": "com.corfirean.csm-modules-preview",
"build": {
"beforeBuildCommand": "npm run build",
+12 -1
View File
@@ -19,6 +19,8 @@ function Main() {
const [servers, setServers] = useState<ServerSummary[] | null>(null);
const [view, setView] = useState<View>(initialView);
const [activeId, setActiveId] = useState<string | null>(null);
// A classic server the owner chose: the install screen makes a modular copy of it next to it.
const [copyFrom, setCopyFrom] = useState<string | null>(null);
const refresh = useCallback(async () => {
const list = await api.list().catch(() => []);
@@ -56,8 +58,13 @@ function Main() {
return (
<InstallServer
canCancel
onCancel={() => setView(servers.length > 0 ? "add" : "welcome")}
copyFrom={copyFrom ?? undefined}
onCancel={() => {
setView(copyFrom ? "import" : servers.length > 0 ? "add" : "welcome");
setCopyFrom(null);
}}
onDone={async (s) => {
setCopyFrom(null);
await refresh();
setActiveId(s.id);
setView("welcome");
@@ -76,6 +83,10 @@ function Main() {
setActiveId(s.id);
setView("welcome");
}}
onCopy={(path) => {
setCopyFrom(path);
setView("install");
}}
/>
);
}
+10 -2
View File
@@ -267,8 +267,16 @@ const en = {
"install.step.unpacking": "Unpacking",
"install.step.database": "Preparing database",
"install.step.finishing": "Finishing",
"install.step.modularCore": "Installing the modular core and building modules",
"install.modularCoreHint": "This step compiles the modules on this computer and can take several minutes.",
"install.step.modularCore": "Installing the modular core",
"install.modularCoreHint": "Modules the old server had switched on are compiled on this computer; this can take several minutes.",
"install.step.copying": "Copying the old server's data",
"install.copy.title": "Modular copy of your server",
"install.copy.subtitle": "Your server has the classic core. A new server with the modular core is installed next to it, and accounts, characters, the world and module settings are copied into it. The old server is not changed; you can keep using it.",
"install.copy.from": "Copy from:",
"install.copy.done": "Copied {accounts} accounts, {characters} characters and {settings} settings files.",
"import.classic.title": "This server has the classic core",
"import.classic.text": "Its modules are compiled into the core. The Modules Preview does not change it: it installs a modular server next to it and copies the data into it. Run one of the two at a time, they use the same ports.",
"import.classic.copy": "Create a modular copy",
"install.classicCoreTitle": "The server stays on the classic core",
"install.classicCoreText": "The server is installed and works, but the modular core did not go on, so its modules are built into the core. You can try again from Updates.",
"install.modulesOffTitle": "Some modules were switched off",
+10 -2
View File
@@ -251,8 +251,16 @@ const ru: Partial<Record<keyof typeof en, string>> = {
"install.step.unpacking": "Распаковка",
"install.step.database": "Подготовка базы данных",
"install.step.finishing": "Завершение",
"install.step.modularCore": "Установка модульного ядра и сборка модулей",
"install.modularCoreHint": "На этом шаге модули компилируются на вашем компьютере, это может занять несколько минут.",
"install.step.modularCore": "Установка модульного ядра",
"install.modularCoreHint": "Модули, которые были включены на старом сервере, компилируются на вашем компьютере; это может занять несколько минут.",
"install.step.copying": "Копирование данных старого сервера",
"install.copy.title": "Модульная копия вашего сервера",
"install.copy.subtitle": "У вашего сервера классическое ядро. Рядом ставится новый сервер с модульным ядром, в него копируются аккаунты, персонажи, мир и настройки модулей. Старый сервер не меняется, им можно продолжать пользоваться.",
"install.copy.from": "Копировать из:",
"install.copy.done": "Скопировано аккаунтов: {accounts}, персонажей: {characters}, файлов настроек: {settings}.",
"import.classic.title": "У этого сервера классическое ядро",
"import.classic.text": "Модули вкомпилированы в ядро. Превью-менеджер его не меняет: он ставит рядом модульный сервер и копирует в него данные. Запускайте один из двух: порты у них одинаковые.",
"import.classic.copy": "Создать модульную копию",
"install.classicCoreTitle": "Сервер остался на классическом ядре",
"install.classicCoreText": "Сервер установлен и работает, но модульное ядро не встало, поэтому модули встроены в ядро. Можно повторить из раздела «Обновления».",
"install.modulesOffTitle": "Часть модулей выключена",
+11 -1
View File
@@ -470,6 +470,14 @@ export interface AccountInfo {
characters: number;
}
export interface CopyReport {
schemas: string[];
accounts: number;
characters: number;
config_files: string[];
read_from: string;
}
export interface InstallStep {
step: string;
percent: number;
@@ -798,7 +806,9 @@ export const api = {
accountSetAccess: (id: string, name: string, level: number) => invoke<void>("account_set_access", { id, name, level }),
accountRename: (id: string, name: string, newName: string, password: string) => invoke<void>("account_rename", { id, name, newName, password }),
accountDelete: (id: string, name: string) => invoke<void>("account_delete", { id, name }),
installNew: (dest: string, pkg?: string, gameData?: string) => invoke<ServerSummary & { preview_core_error: string | null; modules_off: string[] }>("install_new", { dest, package: pkg ?? null, gameData: gameData ?? null }),
installNew: (dest: string, pkg?: string, gameData?: string, copyFrom?: string) =>
invoke<ServerSummary & { preview_core_error: string | null; modules_off: string[]; copied: CopyReport | null }>("install_new", { dest, package: pkg ?? null, gameData: gameData ?? null, copyFrom: copyFrom ?? null }),
isClassicServer: (path: string) => invoke<boolean>("is_classic_server", { path }),
cancelInstall: () => invoke<void>("cancel_install"),
createAccount: (id: string, username: string, password: string, administrator: boolean) =>
invoke<void>("create_account", { id, username, password, administrator }),
+30 -11
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { AlertTriangle, Check, Minus } from "lucide-react";
import { api, asUiError, type Classification, type ScanReport, type ServerSummary, type UiError } from "@/lib/api";
@@ -17,10 +17,17 @@ export function ImportServer(props: {
canCancel: boolean;
onCancel: () => void;
onAdded: (s: ServerSummary) => void | Promise<void>;
onCopy: (path: string) => void;
}) {
const t = useT();
const human = useHuman();
const [report, setReport] = useState<ScanReport | null>(null);
// A classic core (modules compiled in) is not added as it is: the Modules Preview makes a modular copy next to it.
const [classic, setClassic] = useState(false);
useEffect(() => {
setClassic(false);
if (report && report.classification !== "incompatible") void api.isClassicServer(report.path).then(setClassic).catch(() => setClassic(false));
}, [report]);
const [error, setError] = useState<UiError | null>(null);
const [busy, setBusy] = useState(false);
@@ -149,16 +156,28 @@ export function ImportServer(props: {
</p>
))}
<div className="mt-5 flex items-center justify-between border-t border-line pt-4">
<p className="text-sm text-muted">{t("import.noChanges")}</p>
<Button
variant="primary"
onClick={add}
disabled={busy || report.classification === "incompatible"}
>
{t("import.add")}
</Button>
</div>
{classic ? (
<div className="mt-5 border-t border-line pt-4">
<p className="font-medium">{t("import.classic.title")}</p>
<p className="mt-1 text-sm text-muted">{t("import.classic.text")}</p>
<div className="mt-3 flex justify-end">
<Button variant="primary" disabled={busy} onClick={() => props.onCopy(report.path)}>
{t("import.classic.copy")}
</Button>
</div>
</div>
) : (
<div className="mt-5 flex items-center justify-between border-t border-line pt-4">
<p className="text-sm text-muted">{t("import.noChanges")}</p>
<Button
variant="primary"
onClick={add}
disabled={busy || report.classification === "incompatible"}
>
{t("import.add")}
</Button>
</div>
)}
</Card>
)}
</main>
+18 -8
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import { open } from "@tauri-apps/plugin-dialog";
import { Check, ChevronDown, ChevronRight, Loader2 } from "lucide-react";
import { api, asUiError, type InstallEnvironment, type InstallRequirements, type InstallStep, type Preflight, type ServerSummary, type UiError } from "@/lib/api";
import { api, asUiError, type CopyReport, type InstallEnvironment, type InstallRequirements, type InstallStep, type Preflight, type ServerSummary, type UiError } from "@/lib/api";
import { formatBytes } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@@ -17,14 +17,17 @@ const STEP_KEYS: Record<string, Key> = {
"Preparing database": "install.step.database",
Finishing: "install.step.finishing",
"Installing the modular core": "install.step.modularCore",
"Copying the old server's data": "install.step.copying",
};
type Phase = "choose" | "installing" | "done";
export function InstallServer(props: { canCancel: boolean; onCancel: () => void; onDone: (s: ServerSummary) => void | Promise<void> }) {
export function InstallServer(props: { canCancel: boolean; copyFrom?: string; onCancel: () => void; onDone: (s: ServerSummary) => void | Promise<void> }) {
const t = useT();
const human = useHuman();
const [dest, setDest] = useState("C:\\Games\\CoA Server");
const copyFrom = props.copyFrom;
const [dest, setDest] = useState(copyFrom ? copyFrom.replace(/[\\/]+$/, "") + " (modular)" : "C:\\Games\\CoA Server");
const [copied, setCopied] = useState<CopyReport | null>(null);
const [pre, setPre] = useState<Preflight | null>(null);
const [req, setReq] = useState<InstallRequirements | null>(null);
const [phase, setPhase] = useState<Phase>("choose");
@@ -90,8 +93,9 @@ export function InstallServer(props: { canCancel: boolean; onCancel: () => void;
setPhase("installing");
setStep({ step: "Preparing your server", percent: 0, detail: null });
try {
const { preview_core_error, modules_off, ...s } = await api.installNew(dest, pkg.trim() || undefined, docker ? gameData.trim() : undefined);
const { preview_core_error, modules_off, copied, ...s } = await api.installNew(dest, copyFrom ? undefined : pkg.trim() || undefined, docker ? gameData.trim() : undefined, copyFrom);
setInstalled(s);
setCopied(copied);
setClassicCore(preview_core_error);
setModulesOff(modules_off);
setPhase("done");
@@ -117,7 +121,7 @@ export function InstallServer(props: { canCancel: boolean; onCancel: () => void;
<div className="h-full rounded-full bg-gold transition-[width] duration-300" style={{ width: `${step.percent}%` }} />
</div>
{step.detail && <p className="mt-2 text-xs text-muted">{step.detail}</p>}
{step.step === "Installing the modular core" && <p className="mt-2 text-xs text-muted">{t("install.modularCoreHint")}</p>}
{step.step === "Installing the modular core" && copyFrom && <p className="mt-2 text-xs text-muted">{t("install.modularCoreHint")}</p>}
</Card>
{step.step === "Downloading server" && (
<Button className="mt-4 self-start" variant="ghost" size="sm" onClick={() => void api.cancelInstall()}>
@@ -136,6 +140,11 @@ export function InstallServer(props: { canCancel: boolean; onCancel: () => void;
</div>
<h1 className="mt-5 text-2xl font-semibold">{t("install.doneTitle")}</h1>
<p className="mt-1 text-muted">{t("install.doneText")}</p>
{copied && (
<p className="mt-3 text-sm text-muted">
{t("install.copy.done", { accounts: copied.accounts, characters: copied.characters, settings: copied.config_files.length })}
</p>
)}
{classicCore && (
<Card className="mt-6 border-warn/40 p-4 text-left" role="alert">
<p className="font-medium text-warn">{t("install.classicCoreTitle")}</p>
@@ -160,8 +169,9 @@ export function InstallServer(props: { canCancel: boolean; onCancel: () => void;
return (
<main className="mx-auto flex h-full max-w-2xl flex-col justify-center gap-5 px-8 py-10">
<div>
<h1 className="text-2xl font-semibold">{t("install.title")}</h1>
<p className="mt-1 text-muted">{t("install.subtitle")}</p>
<h1 className="text-2xl font-semibold">{copyFrom ? t("install.copy.title") : t("install.title")}</h1>
<p className="mt-1 text-muted">{copyFrom ? t("install.copy.subtitle") : t("install.subtitle")}</p>
{copyFrom && <p className="selectable mt-2 break-all text-sm">{t("install.copy.from")} {copyFrom}</p>}
</div>
<div>
@@ -233,7 +243,7 @@ export function InstallServer(props: { canCancel: boolean; onCancel: () => void;
</Card>
)}
{!docker && <div>
{!docker && !copyFrom && <div>
<button onClick={() => setShowAdvanced((v) => !v)} aria-expanded={showAdvanced} className="flex cursor-pointer items-center gap-1 text-sm text-muted hover:text-ink">
{showAdvanced ? <ChevronDown className="h-4 w-4" aria-hidden /> : <ChevronRight className="h-4 w-4" aria-hidden />}
{t("install.advanced")}
+22 -2
View File
@@ -1032,10 +1032,12 @@ export function ModulesPage({
if (m.id === "tbc-content" || m.id === "wotlk-content") {
return isScalingInstalled;
}
return true;
// Dungeon Clear is part of SQUID's Playerbots (switched with it); its settings open from the SQUID card.
return m.id !== "dungeon-clear";
});
const dungeonClear = modules?.find((m) => m.id === "dungeon-clear" && m.installed && m.has_settings) ?? null;
const selectedModule = modules?.find((m) => m.id === openSettings) ?? null;
const selectedModuleIndex = installedModules.findIndex((m) => m.id === openSettings);
const selectedModuleIndex = installedModules.findIndex((m) => m.id === (openSettings === "dungeon-clear" ? "playerbots" : openSettings));
// Normalize repo URL to "owner/repo" slug
function normalizeUrlSlug(url: string): string {
@@ -1564,6 +1566,13 @@ export function ModulesPage({
: "Mutual exclusion: disables CoA Companions upon activation."}
</p>
)}
{m.id === "playerbots" && dungeonClear && (
<p className="mt-1 text-[11px] text-zinc-400">
{locale === "ru"
? "Включает Dungeon Clear — тактику подземелий для ботов; включается и выключается вместе со SQUID."
: "Includes Dungeon Clear, dungeon tactics for the bots; it is switched on and off with SQUID."}
</p>
)}
</div>
{m.installed && m.switchable && (
<button
@@ -1640,6 +1649,17 @@ export function ModulesPage({
<Settings2 className="h-3.5 w-3.5 mr-1" aria-hidden /> {t("mod.settings")}
</Button>
)}
{m.id === "playerbots" && m.installed && dungeonClear && (
<Button
size="sm"
variant="ghost"
aria-expanded={openSettings === "dungeon-clear"}
onClick={() => setOpenSettings(openSettings === "dungeon-clear" ? null : "dungeon-clear")}
className={cn("text-xs h-7 px-2.5", openSettings === "dungeon-clear" && "bg-gold/15 text-gold border border-gold/40")}
>
<Settings2 className="h-3.5 w-3.5 mr-1" aria-hidden /> Dungeon Clear
</Button>
)}
</div>
{canRemove && (