From 5b5255cca8085c10057fe0a4de5c6eda4628eacf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Wed, 30 Sep 2026 07:33:54 +0500 Subject: [PATCH] Phase 3: config engine (line-preserving parser, schemas, validation, atomic save, snapshots, merge, presets) and Bots/Server settings UI Verified: 28 real config files round-trip byte-identical; native UI edit changes exactly one line in template and generated conf; snapshot restore is byte-exact. Co-Authored-By: Claude Sonnet 5.5 --- crates/coa-core/examples/config_check.rs | 49 ++ crates/coa-core/src/config/merge.rs | 69 ++ crates/coa-core/src/config/mod.rs | 582 ++++++++++++++ crates/coa-core/src/config/parser.rs | 219 +++++ crates/coa-core/src/config/schema.rs | 277 +++++++ crates/coa-core/src/error.rs | 17 +- crates/coa-core/src/lib.rs | 1 + schemas/bots.json | 976 +++++++++++++++++++++++ schemas/presets/bots.json | 67 ++ schemas/presets/server.json | 47 ++ schemas/server.json | 568 +++++++++++++ src-tauri/src/lib.rs | 83 +- src/lib/api.ts | 74 ++ src/screens/SettingsPage.tsx | 424 ++++++++++ src/screens/Shell.tsx | 3 + tests/e2e/cdp.mjs | 15 +- tools/gen_bots_schema.py | 117 +++ tools/gen_server_schema.py | 83 ++ 18 files changed, 3668 insertions(+), 3 deletions(-) create mode 100644 crates/coa-core/examples/config_check.rs create mode 100644 crates/coa-core/src/config/merge.rs create mode 100644 crates/coa-core/src/config/mod.rs create mode 100644 crates/coa-core/src/config/parser.rs create mode 100644 crates/coa-core/src/config/schema.rs create mode 100644 schemas/bots.json create mode 100644 schemas/presets/bots.json create mode 100644 schemas/presets/server.json create mode 100644 schemas/server.json create mode 100644 src/screens/SettingsPage.tsx create mode 100644 tools/gen_bots_schema.py create mode 100644 tools/gen_server_schema.py diff --git a/crates/coa-core/examples/config_check.rs b/crates/coa-core/examples/config_check.rs new file mode 100644 index 0000000..ff50c72 --- /dev/null +++ b/crates/coa-core/examples/config_check.rs @@ -0,0 +1,49 @@ +//! Read-only sanity check of the config engine against a real server folder. +use coa_core::config::{self, parser::ConfFile, Scope}; +use std::path::Path; + +fn main() { + let root = std::env::args().nth(1).expect("usage: config_check "); + let root = Path::new(&root); + + let mut files = 0; + for dir in ["Core/configs", "Core/configs/modules", "Settings"] { + let Ok(rd) = std::fs::read_dir(root.join(dir)) else { continue }; + for e in rd.flatten() { + let p = e.path(); + let name = p.file_name().unwrap().to_string_lossy().to_lowercase(); + if !p.is_file() || !(name.contains(".conf") || name.contains(".template")) { + continue; + } + let bytes = std::fs::read(&p).unwrap(); + match ConfFile::parse_bytes(&bytes) { + Ok(c) => { + assert_eq!(c.to_text().as_bytes(), &bytes[..], "round-trip mismatch: {}", p.display()); + files += 1; + } + Err(e) => println!("skipped {}: {e}", p.display()), + } + } + } + println!("round-trip identical for {files} files"); + + for scope in [Scope::Bots, Scope::Server] { + match config::load(root, scope) { + Ok(v) => { + let present = v.settings.iter().filter(|s| s.present).count(); + let problems: Vec<_> = v.settings.iter().filter(|s| s.problem.is_some()).map(|s| (&s.meta.key, s.problem.clone().unwrap())).collect(); + let missing: Vec<_> = v.settings.iter().filter(|s| !s.present).map(|s| s.meta.key.as_str()).collect(); + let non_default = v.settings.iter().filter(|s| s.present && !s.is_default).count(); + println!( + "{scope:?}: {} settings, {present} present, {non_default} non-default, {} unknown keys, drift {:?}\n problems {:?}\n missing {:?}", + v.settings.len(), v.unknown_keys, v.drift_keys, problems, missing + ); + for p in scope.presets() { + let pv = config::preview_preset(root, scope, &p.id).unwrap(); + println!(" preset {:<14} would change {} settings", p.id, pv.changes.len()); + } + } + Err(e) => println!("{scope:?}: {e}"), + } + } +} diff --git a/crates/coa-core/src/config/merge.rs b/crates/coa-core/src/config/merge.rs new file mode 100644 index 0000000..5255ff9 --- /dev/null +++ b/crates/coa-core/src/config/merge.rs @@ -0,0 +1,69 @@ +//! Non-destructive `.dist` merge: add keys a newer `.dist` introduced, never touch existing values, +//! and only *report* keys the newer `.dist` no longer has. + +use serde::Serialize; + +use super::parser::ConfFile; + +#[derive(Debug, Clone, Default, Serialize)] +pub struct MergePlan { + pub added: Vec, + /// Present in the user's file but unknown to the `.dist` (possibly removed upstream, possibly a custom setting). + pub not_in_dist: Vec, +} + +pub fn plan(conf: &ConfFile, dist: &ConfFile) -> MergePlan { + let added = dist.entries().map(|(k, _)| k).filter(|k| !conf.contains(k)).map(str::to_string).collect::>(); + let not_in_dist = conf.entries().map(|(k, _)| k).filter(|k| !dist.contains(k)).map(str::to_string).collect(); + MergePlan { added: dedup(added), not_in_dist: dedup(not_in_dist) } +} + +fn dedup(mut v: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + v.retain(|k| seen.insert(k.clone())); + v +} + +/// Apply the additive part of the plan to `conf`; returns what was added. +pub fn apply(conf: &mut ConfFile, dist: &ConfFile) -> MergePlan { + let plan = plan(conf, dist); + for key in &plan.added { + let value = dist.get(key).unwrap_or_default().to_string(); + let mut doc = dist.doc_for(key); + doc.push("(added by CoA Server Manager from the updated .dist)".into()); + let refs: Vec<&str> = doc.iter().map(String::as_str).collect(); + conf.set(key, &value, &refs); + } + plan +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adds_new_keys_with_docs_and_preserves_user_values_and_unknowns() { + let mut conf = ConfFile::parse("[worldserver]\nA = 42\n# mine\nCustom.Key = 1\n"); + let dist = ConfFile::parse("[worldserver]\n# About A\nA = 1\n# About B\n# second line\nB = \"x\"\n"); + let before = conf.to_text(); + let p = apply(&mut conf, &dist); + assert_eq!(p.added, ["B"]); + assert_eq!(p.not_in_dist, ["Custom.Key"]); + let out = conf.to_text(); + assert!(out.starts_with(&before), "existing content is untouched"); + assert!(out.contains("# About B\n# second line\n# (added by CoA Server Manager from the updated .dist)\nB = \"x\"\n"), "{out}"); + assert_eq!(conf.get("A"), Some("42")); + assert_eq!(conf.get("Custom.Key"), Some("1")); + // idempotent + assert!(apply(&mut conf, &dist).added.is_empty()); + } + + #[test] + fn removed_upstream_keys_are_reported_not_deleted() { + let mut conf = ConfFile::parse("Old.Key = 5\nKeep = 1\n"); + let dist = ConfFile::parse("Keep = 1\n"); + let p = apply(&mut conf, &dist); + assert_eq!(p.not_in_dist, ["Old.Key"]); + assert_eq!(conf.get("Old.Key"), Some("5")); + } +} diff --git a/crates/coa-core/src/config/mod.rs b/crates/coa-core/src/config/mod.rs new file mode 100644 index 0000000..dc25152 --- /dev/null +++ b/crates/coa-core/src/config/mod.rs @@ -0,0 +1,582 @@ +//! Safe configuration editing: schema-driven, line-preserving, validated, snapshotted and atomically written. + +pub mod merge; +pub mod parser; +pub mod schema; + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use serde::Serialize; +use serde_json::Value; + +use crate::error::{Error, FieldError, Result}; +use crate::fsx; +use parser::ConfFile; +use schema::{values_equal, Category, Preset, PresetFile, Restart, Schema, Setting}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Scope { + Bots, + Server, +} + +impl Scope { + pub fn schema(self) -> &'static Schema { + static BOTS: OnceLock = OnceLock::new(); + static SERVER: OnceLock = OnceLock::new(); + match self { + Scope::Bots => BOTS.get_or_init(|| Schema::parse(include_str!("../../../../schemas/bots.json")).expect("bots schema")), + Scope::Server => SERVER.get_or_init(|| Schema::parse(include_str!("../../../../schemas/server.json")).expect("server schema")), + } + } + + pub fn presets(self) -> &'static [Preset] { + static BOTS: OnceLock = OnceLock::new(); + static SERVER: OnceLock = OnceLock::new(); + let f = match self { + Scope::Bots => BOTS.get_or_init(|| serde_json::from_str(include_str!("../../../../schemas/presets/bots.json")).expect("bots presets")), + Scope::Server => SERVER.get_or_init(|| serde_json::from_str(include_str!("../../../../schemas/presets/server.json")).expect("server presets")), + }; + &f.presets + } + + fn name(self) -> &'static str { + match self { + Scope::Bots => "bots", + Scope::Server => "server", + } + } +} + +/// Files that carry a scope's settings. `read` is what the next server start will use. +#[derive(Debug, Clone)] +pub struct Targets { + pub read: PathBuf, + /// Every file a change must be written to (always includes `read`). + pub writes: Vec, + /// The generated file the launcher rewrites from a template at each start, when applicable. + pub generated: Option, +} + +pub fn targets(root: &Path, scope: Scope) -> Result { + match scope { + Scope::Bots => { + let conf = root.join("Core/configs/modules/mod_coa_playerbots.conf"); + if !conf.is_file() { + return Err(Error::Invalid("CoA Companions (bots) are not installed on this server.".into())); + } + Ok(Targets { read: conf.clone(), writes: vec![conf], generated: None }) + } + Scope::Server => { + let conf = root.join("Core/configs/worldserver.conf"); + let template = root.join("Settings/worldserver.conf.template"); + match (template.is_file(), conf.is_file()) { + (true, true) => Ok(Targets { read: template.clone(), writes: vec![template, conf.clone()], generated: Some(conf) }), + (true, false) => Ok(Targets { read: template.clone(), writes: vec![template], generated: None }), + (false, true) => Ok(Targets { read: conf.clone(), writes: vec![conf], generated: None }), + (false, false) => Err(Error::Invalid("worldserver.conf was not found.".into())), + } + } + } +} + +fn load_conf(path: &Path) -> Result { + ConfFile::parse_bytes(&fs::read(path)?) +} + +fn has_placeholder(raw: &str) -> bool { + let t = raw.trim().trim_matches('"'); + t.starts_with('@') && t.ends_with('@') && t.len() > 2 +} + +#[derive(Debug, Clone, Serialize)] +pub struct SettingView { + #[serde(flatten)] + pub meta: Setting, + pub value: Value, + pub is_default: bool, + /// The key exists in the configuration file (otherwise `value` is the built-in default). + pub present: bool, + /// The file holds a value that is not valid for this setting. + pub problem: Option, + /// Manual edit of the generated file that the launcher will overwrite at next start. + pub drift: bool, +} + +#[derive(Debug, Serialize)] +pub struct SettingsView { + pub scope: Scope, + pub categories: Vec, + pub settings: Vec, + /// Keys in the file the schema does not know (kept untouched). + pub unknown_keys: usize, + pub drift_keys: Vec, + pub files: Vec, +} + +/// Keys whose value in the generated conf differs from the template (i.e. hand edits the launcher will reset). +pub fn drift(root: &Path) -> Result> { + let t = targets(root, Scope::Server)?; + let Some(generated) = &t.generated else { return Ok(Vec::new()) }; + let (template, conf) = (load_conf(&t.read)?, load_conf(generated)?); + let mut keys = BTreeSet::new(); + for (k, v) in conf.entries() { + match template.get(k) { + Some(tv) if has_placeholder(tv) => {} + Some(tv) if tv.trim() != v.trim() => { + keys.insert(k.to_string()); + } + None => { + keys.insert(k.to_string()); + } + _ => {} + } + } + Ok(keys.into_iter().collect()) +} + +pub fn load(root: &Path, scope: Scope) -> Result { + let schema = scope.schema(); + let t = targets(root, scope)?; + let conf = load_conf(&t.read)?; + let drift_keys = if scope == Scope::Server { drift(root).unwrap_or_default() } else { Vec::new() }; + let known: BTreeSet<&str> = schema.settings.iter().map(|s| s.key.as_str()).collect(); + let unknown_keys = conf.entries().filter(|(k, _)| !known.contains(k)).count(); + + let settings = schema + .settings + .iter() + .map(|s| { + let raw = conf.get(&s.key); + let (value, problem) = match raw { + Some(r) => match s.from_raw(r) { + Ok(v) => (v, None), + Err(e) => (s.default.clone(), Some(e)), + }, + None => (s.default.clone(), None), + }; + SettingView { + is_default: values_equal(&value, &s.default), + present: raw.is_some(), + problem, + drift: drift_keys.iter().any(|k| *k == s.key), + value, + meta: s.clone(), + } + }) + .collect(); + + Ok(SettingsView { + scope, + categories: schema.categories.clone(), + settings, + unknown_keys, + drift_keys, + files: t.writes.iter().map(|p| p.to_string_lossy().into_owned()).collect(), + }) +} + +/// Pairs that must satisfy `min <= max` after a save. +const ORDERED_PAIRS: [(&str, &str); 6] = [ + ("CoaBots.Profile.IntentMinMs", "CoaBots.Profile.IntentMaxMs"), + ("CoaBots.WorldBrain.PlannerMinMs", "CoaBots.WorldBrain.PlannerMaxMs"), + ("CoaBots.WorldBrain.ScanMinMs", "CoaBots.WorldBrain.ScanMaxMs"), + ("CoaBots.WorldBrain.MountDistanceMin", "CoaBots.WorldBrain.MountDistanceMax"), + ("CoaBots.WorldBrain.PartyMinMinutes", "CoaBots.WorldBrain.PartyMaxMinutes"), + ("CoaBots.WorldBrain.QuestSuspendMs", "CoaBots.WorldBrain.QuestSuspendMaxMs"), +]; + +#[derive(Debug, Clone, Serialize)] +pub struct Change { + pub key: String, + pub title: String, + pub from: Option, + pub to: Value, + pub restart: Restart, + pub dangerous: bool, +} + +#[derive(Debug, Serialize)] +pub struct SaveReport { + pub changed: Vec, + /// Strongest restart any changed setting needs, if any changed. + pub restart: Option, + pub snapshot: Option, +} + +fn effective(view: &SettingsView, changes: &BTreeMap, key: &str) -> Option { + changes + .get(key) + .and_then(Value::as_f64) + .or_else(|| view.settings.iter().find(|s| s.meta.key == key).and_then(|s| s.value.as_f64())) +} + +/// Validate `changes` against the schema without writing anything. +pub fn validate(root: &Path, scope: Scope, changes: &BTreeMap) -> Result> { + let schema = scope.schema(); + let mut errors = Vec::new(); + let mut raws = BTreeMap::new(); + for (key, value) in changes { + match schema.get(key) { + None => errors.push(FieldError { key: key.clone(), message: "is not a setting the Manager can change".into() }), + Some(s) => match s.to_raw(value) { + Ok(raw) => { + raws.insert(key.clone(), raw); + } + Err(m) => errors.push(FieldError { key: key.clone(), message: m }), + }, + } + } + if errors.is_empty() { + let view = load(root, scope)?; + for (lo, hi) in ORDERED_PAIRS { + if !(changes.contains_key(lo) || changes.contains_key(hi)) { + continue; + } + if let (Some(a), Some(b)) = (effective(&view, changes, lo), effective(&view, changes, hi)) { + if a > b { + let key = if changes.contains_key(lo) { lo } else { hi }; + errors.push(FieldError { key: key.into(), message: format!("the minimum ({lo}) cannot be larger than the maximum ({hi})") }); + } + } + } + } + if errors.is_empty() { + Ok(raws) + } else { + Err(Error::Validation(errors)) + } +} + +/// Where snapshots of a scope's files are kept. +fn snapshot_root(meta_dir: &Path) -> PathBuf { + meta_dir.join("backups").join("config") +} + +const KEEP_SNAPSHOTS: usize = 40; + +#[derive(Debug, Clone, Serialize, serde::Deserialize)] +struct SnapshotFile { + original: String, + stored: String, +} + +#[derive(Debug, Clone, Serialize, serde::Deserialize)] +pub struct SnapshotInfo { + pub id: String, + pub scope: Scope, + pub reason: String, + pub created_at: String, + files: Vec, +} + +fn take_snapshot(meta_dir: &Path, scope: Scope, reason: &str, files: &[(PathBuf, Vec)]) -> Result { + let id = format!("{}-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S%3f"), scope.name()); + let dir = snapshot_root(meta_dir).join(&id); + fs::create_dir_all(&dir)?; + let mut entries = Vec::new(); + for (i, (path, bytes)) in files.iter().enumerate() { + let stored = format!("{i}_{}", path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()); + fsx::atomic_write(&dir.join(&stored), bytes)?; + entries.push(SnapshotFile { original: path.to_string_lossy().into_owned(), stored }); + } + let info = SnapshotInfo { id: id.clone(), scope, reason: reason.into(), created_at: chrono::Utc::now().to_rfc3339(), files: entries }; + fsx::atomic_write_json(&dir.join("snapshot.json"), &info)?; + prune_snapshots(meta_dir); + Ok(id) +} + +fn prune_snapshots(meta_dir: &Path) { + let Ok(rd) = fs::read_dir(snapshot_root(meta_dir)) else { return }; + let mut dirs: Vec<_> = rd.filter_map(|e| e.ok()).filter(|e| e.path().join("snapshot.json").is_file()).map(|e| e.path()).collect(); + dirs.sort(); + while dirs.len() > KEEP_SNAPSHOTS { + let _ = fs::remove_dir_all(dirs.remove(0)); + } +} + +pub fn list_snapshots(meta_dir: &Path) -> Vec { + let mut out: Vec = fs::read_dir(snapshot_root(meta_dir)) + .map(|rd| rd.filter_map(|e| e.ok()).filter_map(|e| fsx::read_json(&e.path().join("snapshot.json")).ok()).collect()) + .unwrap_or_default(); + out.sort_by(|a, b| b.id.cmp(&a.id)); + out +} + +/// Apply validated `changes`. Nothing is written unless every change is valid; previous contents are snapshotted first. +pub fn save(root: &Path, meta_dir: &Path, scope: Scope, changes: &BTreeMap) -> Result { + let raws = validate(root, scope, changes)?; + let schema = scope.schema(); + let t = targets(root, scope)?; + let current = load(root, scope)?; + + let mut originals: Vec<(PathBuf, Vec)> = Vec::new(); + let mut confs: Vec = Vec::new(); + for p in &t.writes { + let bytes = fs::read(p)?; + confs.push(ConfFile::parse_bytes(&bytes)?); + originals.push((p.clone(), bytes)); + } + + let mut changed = Vec::new(); + for (key, raw) in &raws { + let setting = schema.get(key).expect("validated"); + let view = current.settings.iter().find(|s| s.meta.key == *key).expect("in schema"); + let same_everywhere = confs.iter().all(|c| c.get(key).map(str::trim) == Some(raw.as_str())); + if same_everywhere { + continue; + } + for c in &mut confs { + c.set(key, raw, &["Added by CoA Server Manager"]); + } + changed.push(Change { + key: key.clone(), + title: setting.title.clone(), + from: view.present.then(|| view.value.clone()), + to: changes[key].clone(), + restart: setting.restart_required, + dangerous: setting.dangerous, + }); + } + if changed.is_empty() { + return Ok(SaveReport { changed, restart: None, snapshot: None }); + } + + // Verify what we are about to write before touching the disk. + let texts: Vec = confs.iter().map(ConfFile::to_text).collect(); + for text in &texts { + let reread = ConfFile::parse(text); + for c in &changed { + if reread.get(&c.key).map(str::trim) != Some(raws[&c.key].as_str()) { + return Err(Error::Invalid(format!("internal check failed for {}", c.key))); + } + } + } + + let reason = format!("before changing {} setting(s)", changed.len()); + let snapshot = take_snapshot(meta_dir, scope, &reason, &originals)?; + + let mut written = 0; + for (i, (path, _)) in originals.iter().enumerate() { + if let Err(e) = fsx::atomic_write(path, texts[i].as_bytes()) { + for (p, bytes) in originals.iter().take(written) { + let _ = fsx::atomic_write(p, bytes); + } + return Err(e); + } + written += 1; + } + let restart = changed.iter().map(|c| c.restart).max(); + tracing::info!(scope = scope.name(), changed = changed.len(), %snapshot, "configuration saved"); + Ok(SaveReport { changed, restart, snapshot: Some(snapshot) }) +} + +/// Put a snapshot's files back (after snapshotting the current state, so a restore is itself undoable). +pub fn restore_snapshot(meta_dir: &Path, id: &str) -> Result<()> { + if id.contains(['/', '\\']) || id.contains("..") { + return Err(Error::PathRejected(id.into())); + } + let dir = snapshot_root(meta_dir).join(id); + let info: SnapshotInfo = fsx::read_json(&dir.join("snapshot.json"))?; + let mut current = Vec::new(); + for f in &info.files { + let p = PathBuf::from(&f.original); + if let Ok(bytes) = fs::read(&p) { + current.push((p, bytes)); + } + } + take_snapshot(meta_dir, info.scope, "before restoring a snapshot", ¤t)?; + for f in &info.files { + fsx::atomic_write(Path::new(&f.original), &fs::read(dir.join(&f.stored))?)?; + } + Ok(()) +} + +#[derive(Debug, Clone, Serialize)] +pub struct PresetChange { + pub key: String, + pub title: String, + pub from: Value, + pub to: Value, + pub dangerous: bool, +} + +#[derive(Debug, Serialize)] +pub struct PresetPreview { + pub id: String, + pub title: String, + pub description: String, + pub changes: Vec, +} + +fn diff(view: &SettingsView, wanted: impl Iterator) -> Vec { + wanted + .filter_map(|(key, to)| { + let s = view.settings.iter().find(|s| s.meta.key == key)?; + (!values_equal(&s.value, &to) || !s.present).then(|| PresetChange { title: s.meta.title.clone(), from: s.value.clone(), dangerous: s.meta.dangerous, key, to }) + }) + .filter(|c| !values_equal(&c.from, &c.to)) + .collect() +} + +/// What applying a preset would change. Nothing is written; the UI shows "This preset will change N settings". +pub fn preview_preset(root: &Path, scope: Scope, preset_id: &str) -> Result { + let preset = scope.presets().iter().find(|p| p.id == preset_id).ok_or_else(|| Error::Invalid(format!("unknown preset {preset_id}")))?; + let view = load(root, scope)?; + let changes = diff(&view, preset.values.iter().map(|(k, v)| (k.clone(), v.clone()))); + Ok(PresetPreview { id: preset.id.clone(), title: preset.title.clone(), description: preset.description.clone(), changes }) +} + +/// "Restore recommended defaults": every non-default setting back to its schema default. +pub fn preview_defaults(root: &Path, scope: Scope) -> Result { + let view = load(root, scope)?; + let changes = diff(&view, view.settings.iter().map(|s| (s.meta.key.clone(), s.meta.default.clone()))); + Ok(PresetPreview { id: "defaults".into(), title: "Recommended defaults".into(), description: "Every setting returns to its default value.".into(), changes }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn fixture() -> (tempfile::TempDir, PathBuf, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("srv"); + let meta = dir.path().join("srv.manager"); + crate::layout::testkit::fake_repack(&root); + fs::create_dir_all(root.join("Settings")).unwrap(); + let world = "# Server\r\n[worldserver]\r\nRealmID = 1\r\nWorldServerPort = @WORLD_PORT@\r\n\r\n# XP\r\nRate.XP.Kill = 1\r\nRate.Drop.Money = 1\r\nUnknown.Custom = \"keep me\"\r\nPlayerLimit = 0\r\n"; + fs::write(root.join("Settings/worldserver.conf.template"), world).unwrap(); + fs::write(root.join("Core/configs/worldserver.conf"), world.replace("@WORLD_PORT@", "8085")).unwrap(); + fs::create_dir_all(&meta).unwrap(); + (dir, root, meta) + } + + fn set(pairs: &[(&str, Value)]) -> BTreeMap { + pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect() + } + + #[test] + fn schemas_and_presets_are_self_consistent() { + for scope in [Scope::Bots, Scope::Server] { + let schema = scope.schema(); + assert!(!schema.settings.is_empty()); + for p in scope.presets() { + for (k, v) in &p.values { + let s = schema.get(k).unwrap_or_else(|| panic!("preset {} references unknown {k}", p.id)); + s.to_raw(v).unwrap_or_else(|e| panic!("preset {} value for {k}: {e}", p.id)); + } + } + } + } + + #[test] + fn load_reads_values_defaults_and_counts_unknowns() { + let (_d, root, _m) = fixture(); + let v = load(&root, Scope::Server).unwrap(); + let xp = v.settings.iter().find(|s| s.meta.key == "Rate.XP.Kill").unwrap(); + assert_eq!(xp.value, json!(1.0)); + assert!(xp.present && xp.is_default); + let quest = v.settings.iter().find(|s| s.meta.key == "Rate.XP.Quest").unwrap(); + assert!(!quest.present, "missing key falls back to default"); + assert!(v.unknown_keys >= 3, "RealmID, WorldServerPort, Unknown.Custom are not curated"); + assert!(v.drift_keys.is_empty()); + } + + #[test] + fn save_changes_only_that_key_in_template_and_generated_conf() { + let (_d, root, meta) = fixture(); + let tpl_before = fs::read_to_string(root.join("Settings/worldserver.conf.template")).unwrap(); + let r = save(&root, &meta, Scope::Server, &set(&[("Rate.XP.Kill", json!(2.5))])).unwrap(); + assert_eq!(r.changed.len(), 1); + assert_eq!(r.restart, Some(Restart::World)); + for f in ["Settings/worldserver.conf.template", "Core/configs/worldserver.conf"] { + let t = fs::read_to_string(root.join(f)).unwrap(); + assert!(t.contains("Rate.XP.Kill = 2.5\r\n"), "{f}: {t:?}"); + assert!(t.contains("Unknown.Custom = \"keep me\"\r\n")); + } + let tpl_after = fs::read_to_string(root.join("Settings/worldserver.conf.template")).unwrap(); + assert_eq!(tpl_after.replace("= 2.5", "= 1"), tpl_before, "nothing but the one value changed"); + assert!(tpl_after.contains("@WORLD_PORT@"), "template placeholders survive"); + } + + #[test] + fn invalid_input_writes_nothing() { + let (_d, root, meta) = fixture(); + let before = fs::read(root.join("Core/configs/worldserver.conf")).unwrap(); + let e = save(&root, &meta, Scope::Server, &set(&[("Rate.XP.Kill", json!(3)), ("Rate.Drop.Money", json!(-4)), ("Nope.Key", json!(1))])).unwrap_err(); + match e { + Error::Validation(f) => { + assert_eq!(f.len(), 2); + assert!(f.iter().any(|x| x.key == "Rate.Drop.Money")); + } + other => panic!("{other:?}"), + } + assert_eq!(fs::read(root.join("Core/configs/worldserver.conf")).unwrap(), before); + assert!(list_snapshots(&meta).is_empty(), "no snapshot for a rejected save"); + } + + #[test] + fn missing_key_is_appended_and_noop_saves_do_nothing() { + let (_d, root, meta) = fixture(); + let r = save(&root, &meta, Scope::Server, &set(&[("Rate.XP.Quest", json!(2))])).unwrap(); + assert_eq!(r.changed.len(), 1); + assert!(r.changed[0].from.is_none()); + let t = fs::read_to_string(root.join("Core/configs/worldserver.conf")).unwrap(); + assert!(t.ends_with("# Added by CoA Server Manager\r\nRate.XP.Quest = 2\r\n"), "{t:?}"); + let again = save(&root, &meta, Scope::Server, &set(&[("Rate.XP.Quest", json!(2))])).unwrap(); + assert!(again.changed.is_empty() && again.snapshot.is_none()); + } + + #[test] + fn snapshot_restore_roundtrip_is_byte_exact_and_undoable() { + let (_d, root, meta) = fixture(); + let conf = root.join("Core/configs/worldserver.conf"); + let original = fs::read(&conf).unwrap(); + save(&root, &meta, Scope::Server, &set(&[("PlayerLimit", json!(50))])).unwrap(); + assert_ne!(fs::read(&conf).unwrap(), original); + let snap = list_snapshots(&meta).remove(0); + restore_snapshot(&meta, &snap.id).unwrap(); + assert_eq!(fs::read(&conf).unwrap(), original); + assert!(list_snapshots(&meta).len() >= 2, "restore took its own snapshot"); + assert!(restore_snapshot(&meta, "../evil").is_err()); + } + + #[test] + fn drift_detects_manual_edits_to_the_generated_conf_only() { + let (_d, root, _m) = fixture(); + let conf = root.join("Core/configs/worldserver.conf"); + let t = fs::read_to_string(&conf).unwrap().replace("PlayerLimit = 0", "PlayerLimit = 99"); + fs::write(&conf, t).unwrap(); + assert_eq!(drift(&root).unwrap(), ["PlayerLimit"]); + let v = load(&root, Scope::Server).unwrap(); + assert!(v.settings.iter().find(|s| s.meta.key == "PlayerLimit").unwrap().drift); + } + + #[test] + fn bots_ordering_rule_and_preset_preview() { + let (_d, root, meta) = fixture(); + fs::write(root.join("Core/configs/modules/mod_coa_playerbots.conf"), "[worldserver]\nCoaBots.WorldBrain.PlannerMinMs = 2000\nCoaBots.WorldBrain.PlannerMaxMs = 8000\nCoaBots.Combat.VerboseLog = 1\n").unwrap(); + let e = save(&root, &meta, Scope::Bots, &set(&[("CoaBots.WorldBrain.PlannerMinMs", json!(9000))])).unwrap_err(); + assert!(matches!(e, Error::Validation(_))); + let p = preview_preset(&root, Scope::Bots, "balanced").unwrap(); + assert!(p.changes.iter().any(|c| c.key == "CoaBots.Combat.VerboseLog" && c.to == json!(false))); + let d = preview_defaults(&root, Scope::Bots).unwrap(); + assert!(d.changes.iter().all(|c| !values_equal(&c.from, &c.to))); + // applying a preview through save() works end to end + let map: BTreeMap<_, _> = p.changes.iter().map(|c| (c.key.clone(), c.to.clone())).collect(); + save(&root, &meta, Scope::Bots, &map).unwrap(); + assert!(preview_preset(&root, Scope::Bots, "balanced").unwrap().changes.is_empty(), "idempotent"); + } + + #[test] + fn bots_scope_requires_the_module_config() { + let (_d, root, _m) = fixture(); + fs::remove_file(root.join("Core/configs/modules/mod_coa_playerbots.conf")).unwrap(); + assert!(load(&root, Scope::Bots).is_err()); + } +} diff --git a/crates/coa-core/src/config/parser.rs b/crates/coa-core/src/config/parser.rs new file mode 100644 index 0000000..cc2d3e5 --- /dev/null +++ b/crates/coa-core/src/config/parser.rs @@ -0,0 +1,219 @@ +//! Line-preserving reader/writer for AzerothCore `.conf` files. +//! +//! Invariant: `parse(text).to_text() == text` for any valid UTF-8 input. Every line keeps its own line ending, +//! comments/blank lines/unknown keys are never touched, and `set` rewrites only the value part of one line. + +use crate::error::{Error, Result}; + +#[derive(Debug, Clone)] +enum Kind { + Other, + Entry { key: String, prefix_len: usize }, +} + +#[derive(Debug, Clone)] +struct Line { + /// Text without its line terminator. + text: String, + /// The terminator this line had ("\n", "\r\n" or "" for an unterminated last line). + eol: String, + kind: Kind, +} + +#[derive(Debug, Clone)] +pub struct ConfFile { + lines: Vec, + bom: bool, + default_eol: &'static str, +} + +fn is_key_char(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') +} + +fn classify(text: &str) -> Kind { + let t = text.trim_start(); + if t.is_empty() || t.starts_with('#') || t.starts_with(';') || t.starts_with('[') { + return Kind::Other; + } + let Some(eq) = text.find('=') else { return Kind::Other }; + let key = text[..eq].trim(); + if key.is_empty() || !key.chars().all(is_key_char) { + return Kind::Other; + } + // prefix = everything up to and including '=' and the spaces after it + let after = &text[eq + 1..]; + let spaces = after.len() - after.trim_start_matches([' ', '\t']).len(); + Kind::Entry { key: key.to_string(), prefix_len: eq + 1 + spaces } +} + +impl ConfFile { + pub fn parse(input: &str) -> ConfFile { + let (bom, body) = match input.strip_prefix('\u{feff}') { + Some(rest) => (true, rest), + None => (false, input), + }; + let mut lines = Vec::new(); + let (mut crlf, mut lf) = (0usize, 0usize); + let mut rest = body; + while !rest.is_empty() { + let (raw, eol, next) = match rest.find('\n') { + Some(i) if i > 0 && rest.as_bytes()[i - 1] == b'\r' => (&rest[..i - 1], "\r\n", &rest[i + 1..]), + Some(i) => (&rest[..i], "\n", &rest[i + 1..]), + None => (rest, "", ""), + }; + match eol { + "\r\n" => crlf += 1, + "\n" => lf += 1, + _ => {} + } + lines.push(Line { text: raw.to_string(), eol: eol.to_string(), kind: classify(raw) }); + rest = next; + } + ConfFile { lines, bom, default_eol: if crlf > lf { "\r\n" } else { "\n" } } + } + + pub fn parse_bytes(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| Error::Invalid("configuration file is not valid UTF-8".into()))?; + Ok(Self::parse(text)) + } + + pub fn to_text(&self) -> String { + let mut out = String::new(); + if self.bom { + out.push('\u{feff}'); + } + for l in &self.lines { + out.push_str(&l.text); + out.push_str(&l.eol); + } + out + } + + /// Active (uncommented) entries in file order. + pub fn entries(&self) -> impl Iterator { + self.lines.iter().filter_map(|l| match &l.kind { + Kind::Entry { key, prefix_len } => Some((key.as_str(), l.text[*prefix_len..].trim_end())), + Kind::Other => None, + }) + } + + /// Raw value (quotes included) of the last active occurrence of `key` (the one the server ends up using). + pub fn get(&self, key: &str) -> Option<&str> { + self.entries().filter(|(k, _)| *k == key).map(|(_, v)| v).last() + } + + pub fn contains(&self, key: &str) -> bool { + self.get(key).is_some() + } + + /// Set `raw` (already formatted, quotes included) as the value of `key`. Returns true if the key was appended. + /// `doc` lines (without `#`) are written above a newly appended key. + pub fn set(&mut self, key: &str, raw: &str, doc: &[&str]) -> bool { + if let Some(l) = self.lines.iter_mut().rev().find(|l| matches!(&l.kind, Kind::Entry { key: k, .. } if k == key)) { + if let Kind::Entry { prefix_len, .. } = l.kind { + let prefix = l.text[..prefix_len].to_string(); + l.text = format!("{prefix}{raw}"); + } + return false; + } + let eol = self.default_eol; + // Make sure the previous line is terminated before appending. + if let Some(last) = self.lines.last_mut() { + if last.eol.is_empty() { + last.eol = eol.to_string(); + } + } + for d in doc { + self.lines.push(Line { text: format!("# {d}"), eol: eol.to_string(), kind: Kind::Other }); + } + let text = format!("{key} = {raw}"); + self.lines.push(Line { kind: classify(&text), text, eol: eol.to_string() }); + true + } + + /// The comment block directly above the last active occurrence of `key` (without the leading `#`). + pub fn doc_for(&self, key: &str) -> Vec { + let Some(idx) = self.lines.iter().rposition(|l| matches!(&l.kind, Kind::Entry { key: k, .. } if k == key)) else { + return Vec::new(); + }; + let mut doc = Vec::new(); + for l in self.lines[..idx].iter().rev() { + let t = l.text.trim(); + if !t.starts_with('#') || t.trim_start_matches('#').trim().is_empty() { + break; + } + doc.push(t.trim_start_matches('#').trim().to_string()); + } + doc.reverse(); + doc + } + + pub fn append_comment_block(&mut self, doc: &[&str]) { + let eol = self.default_eol; + if let Some(last) = self.lines.last_mut() { + if last.eol.is_empty() { + last.eol = eol.to_string(); + } + } + for d in doc { + self.lines.push(Line { text: d.to_string(), eol: eol.to_string(), kind: Kind::Other }); + } + } +} + +/// Strip one pair of surrounding double quotes. +pub fn unquote(raw: &str) -> &str { + let t = raw.trim(); + t.strip_prefix('"').and_then(|s| s.strip_suffix('"')).unwrap_or(t) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = "# header\r\n[worldserver]\r\n\r\n# doc for a\r\nA.Key = 5\r\nB.Key=\"text with # hash\"\r\n#Commented = 1\r\nC = 7\r\nA.Key = 6\r\nlast = 1"; + + #[test] + fn roundtrip_is_byte_identical_including_odd_input() { + for s in [SAMPLE, "", "\n", "no newline", "a=1\n\n\nb = 2\n", "\u{feff}k = v\r\n", "mixed=1\nendings=2\r\nhere=3\n", "=novalue\nweird key = 1\n"] { + assert_eq!(ConfFile::parse(s).to_text(), s, "input: {s:?}"); + } + } + + #[test] + fn get_returns_last_active_value_and_ignores_comments() { + let f = ConfFile::parse(SAMPLE); + assert_eq!(f.get("A.Key"), Some("6")); + assert_eq!(f.get("B.Key"), Some("\"text with # hash\"")); + assert_eq!(f.get("Commented"), None); + assert_eq!(f.get("C"), Some("7")); + assert_eq!(unquote(f.get("B.Key").unwrap()), "text with # hash"); + } + + #[test] + fn set_rewrites_only_the_value_and_keeps_spacing() { + let mut f = ConfFile::parse(SAMPLE); + assert!(!f.set("C", "9", &[])); + assert!(!f.set("A.Key", "10", &[])); + let out = f.to_text(); + assert!(out.contains("C = 9\r\n"), "{out:?}"); + // last duplicate changed, first untouched + assert!(out.contains("A.Key = 5\r\n") && out.contains("A.Key = 10\r\n")); + // everything else byte-identical + assert_eq!(out.replace("C = 9", "C = 7").replace("A.Key = 10", "A.Key = 6"), SAMPLE); + } + + #[test] + fn set_appends_missing_key_with_doc_using_file_eol() { + let mut f = ConfFile::parse("[worldserver]\r\nA = 1"); + assert!(f.set("New.Key", "\"x\"", &["Added by CoA Server Manager"])); + assert_eq!(f.to_text(), "[worldserver]\r\nA = 1\r\n# Added by CoA Server Manager\r\nNew.Key = \"x\"\r\n"); + assert_eq!(f.get("New.Key"), Some("\"x\"")); + } + + #[test] + fn non_utf8_is_refused_rather_than_mangled() { + assert!(ConfFile::parse_bytes(&[0x41, 0xFF, 0xFE]).is_err()); + } +} diff --git a/crates/coa-core/src/config/schema.rs b/crates/coa-core/src/config/schema.rs new file mode 100644 index 0000000..49068cd --- /dev/null +++ b/crates/coa-core/src/config/schema.rs @@ -0,0 +1,277 @@ +//! Setting schemas (`schemas/bots.json`, `schemas/server.json`) and value validation/formatting. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::error::{Error, Result}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SettingType { + Bool, + Int, + Float, + String, + Enum, +} + +/// How much has to be restarted for a change to take effect. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Restart { + Runtime, + World, + Full, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnumOption { + pub value: Value, + pub label: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Setting { + pub key: String, + #[serde(rename = "type")] + pub ty: SettingType, + pub category: String, + pub title: String, + pub description: String, + pub default: Value, + #[serde(default)] + pub min: Option, + #[serde(default)] + pub max: Option, + #[serde(default)] + pub options: Vec, + #[serde(default)] + pub unit: Option, + #[serde(default)] + pub advanced: bool, + pub restart_required: Restart, + #[serde(default)] + pub dangerous: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Category { + pub id: String, + pub title: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Schema { + pub schema: u32, + pub scope: String, + pub categories: Vec, + pub settings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Preset { + pub id: String, + pub title: String, + pub description: String, + pub values: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PresetFile { + pub schema: u32, + pub presets: Vec, +} + +impl Schema { + pub fn parse(json: &str) -> Result { + let s: Schema = serde_json::from_str(json)?; + s.check()?; + Ok(s) + } + + pub fn get(&self, key: &str) -> Option<&Setting> { + self.settings.iter().find(|s| s.key == key) + } + + /// Structural sanity of the schema itself (caught by tests, not at user runtime). + fn check(&self) -> Result<()> { + let bad = |m: String| Err(Error::Invalid(format!("schema {}: {m}", self.scope))); + let cats: Vec<&str> = self.categories.iter().map(|c| c.id.as_str()).collect(); + let mut seen = std::collections::HashSet::new(); + for s in &self.settings { + if !seen.insert(&s.key) { + return bad(format!("duplicate key {}", s.key)); + } + if !cats.contains(&s.category.as_str()) { + return bad(format!("{}: unknown category {}", s.key, s.category)); + } + if s.ty == SettingType::Enum && s.options.is_empty() { + return bad(format!("{}: enum without options", s.key)); + } + if let Err(e) = s.to_raw(&s.default) { + return bad(format!("{}: default is invalid: {e}", s.key)); + } + } + Ok(()) + } +} + +/// JSON equality that treats 1 and 1.0 as the same number. +pub fn values_equal(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => x.as_f64() == y.as_f64(), + _ => a == b, + } +} + +fn num_fmt(f: f64) -> String { + if f.fract() == 0.0 && f.abs() < 1e15 { + format!("{}", f as i64) + } else { + let s = format!("{f}"); + s + } +} + +impl Setting { + /// Validate `value` and format it the way it is written to the `.conf` (strings are quoted). + pub fn to_raw(&self, value: &Value) -> std::result::Result { + let range = |n: f64| -> std::result::Result<(), String> { + if let Some(min) = self.min { + if n < min { + return Err(format!("must be at least {}", num_fmt(min))); + } + } + if let Some(max) = self.max { + if n > max { + return Err(format!("must be at most {}", num_fmt(max))); + } + } + Ok(()) + }; + match self.ty { + SettingType::Bool => match value { + Value::Bool(b) => Ok(if *b { "1" } else { "0" }.into()), + _ => Err("must be on or off".into()), + }, + SettingType::Int => match value.as_i64() { + Some(i) => range(i as f64).map(|_| i.to_string()), + None => Err("must be a whole number".into()), + }, + SettingType::Float => match value.as_f64() { + Some(f) if f.is_finite() => range(f).map(|_| num_fmt(f)), + _ => Err("must be a number".into()), + }, + SettingType::String => match value.as_str() { + Some(s) if s.contains(['"', '\n', '\r']) => Err("must not contain quotes or line breaks".into()), + Some(s) => Ok(format!("\"{s}\"")), + None => Err("must be text".into()), + }, + SettingType::Enum => { + let opt = self.options.iter().find(|o| o.value == *value).ok_or("is not one of the allowed choices")?; + Ok(match &opt.value { + Value::String(s) => format!("\"{s}\""), + other => other.to_string(), + }) + } + } + } + + /// Decode a raw `.conf` value into JSON according to the setting's type. + pub fn from_raw(&self, raw: &str) -> std::result::Result { + let t = raw.trim(); + let unq = super::parser::unquote(t); + match self.ty { + SettingType::Bool => match unq.to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Ok(Value::Bool(true)), + "0" | "false" | "no" | "off" => Ok(Value::Bool(false)), + _ => Err(format!("{t:?} is not on/off")), + }, + SettingType::Int => unq.parse::().map(Value::from).map_err(|_| format!("{t:?} is not a whole number")), + SettingType::Float => unq + .parse::() + .ok() + .and_then(serde_json::Number::from_f64) + .map(Value::Number) + .ok_or_else(|| format!("{t:?} is not a number")), + SettingType::String => Ok(Value::String(unq.to_string())), + SettingType::Enum => { + for o in &self.options { + let matches = match &o.value { + Value::String(s) => s == unq, + other => other.to_string() == unq, + }; + if matches { + return Ok(o.value.clone()); + } + } + Err(format!("{t:?} is not one of the known choices")) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn s(ty: SettingType, min: Option, max: Option) -> Setting { + Setting { + key: "K".into(), + ty, + category: "c".into(), + title: "t".into(), + description: "d".into(), + default: json!(1), + min, + max, + options: vec![], + unit: None, + advanced: false, + restart_required: Restart::World, + dangerous: false, + } + } + + #[test] + fn int_range_and_type_validation() { + let st = s(SettingType::Int, Some(0.0), Some(100.0)); + assert_eq!(st.to_raw(&json!(50)).unwrap(), "50"); + assert!(st.to_raw(&json!(101)).is_err()); + assert!(st.to_raw(&json!(-1)).is_err()); + assert!(st.to_raw(&json!(1.5)).is_err()); + assert!(st.to_raw(&json!("5")).is_err()); + } + + #[test] + fn float_bool_string_formatting() { + let f = s(SettingType::Float, Some(0.0), None); + assert_eq!(f.to_raw(&json!(1.5)).unwrap(), "1.5"); + assert_eq!(f.to_raw(&json!(2.0)).unwrap(), "2"); + assert!(f.to_raw(&json!(-0.1)).is_err()); + assert_eq!(s(SettingType::Bool, None, None).to_raw(&json!(true)).unwrap(), "1"); + assert!(s(SettingType::Bool, None, None).to_raw(&json!(1)).is_err()); + let st = s(SettingType::String, None, None); + assert_eq!(st.to_raw(&json!("a b")).unwrap(), "\"a b\""); + assert!(st.to_raw(&json!("bad\"quote")).is_err()); + assert!(st.to_raw(&json!("multi\nline")).is_err()); + } + + #[test] + fn enum_and_decode() { + let mut e = s(SettingType::Enum, None, None); + e.options = vec![EnumOption { value: json!(0), label: "A".into() }, EnumOption { value: json!(2), label: "B".into() }]; + assert_eq!(e.to_raw(&json!(2)).unwrap(), "2"); + assert!(e.to_raw(&json!(1)).is_err()); + assert_eq!(e.from_raw("2").unwrap(), json!(2)); + assert!(e.from_raw("9").is_err()); + assert_eq!(s(SettingType::Bool, None, None).from_raw(" 1 ").unwrap(), json!(true)); + assert_eq!(s(SettingType::String, None, None).from_raw("\"x y\"").unwrap(), json!("x y")); + assert_eq!(s(SettingType::Float, None, None).from_raw("1.5").unwrap(), json!(1.5)); + assert!(s(SettingType::Int, None, None).from_raw("abc").is_err()); + } +} diff --git a/crates/coa-core/src/error.rs b/crates/coa-core/src/error.rs index 9a64b02..cb94b4e 100644 --- a/crates/coa-core/src/error.rs +++ b/crates/coa-core/src/error.rs @@ -20,6 +20,15 @@ pub enum Error { UnknownInstallation(String), #[error("{0}")] Invalid(String), + #[error("invalid values: {}", .0.iter().map(|f| format!("{}: {}", f.key, f.message)).collect::>().join("; "))] + Validation(Vec), +} + +/// A problem with one setting, addressed by key so the UI can highlight the field. +#[derive(Debug, Clone, Serialize)] +pub struct FieldError { + pub key: String, + pub message: String, } /// Stable identifiers the UI maps to human messages. Raw exit codes never reach the user. @@ -129,6 +138,7 @@ impl Error { Error::InsufficientSpace { .. } => ErrorCode::DiskFull, Error::HashMismatch { .. } => ErrorCode::HashMismatch, Error::PathRejected(_) => ErrorCode::PathRejected, + Error::Validation(_) => ErrorCode::InvalidConfigValue, _ => ErrorCode::Unknown, } } @@ -139,10 +149,15 @@ impl Error { pub struct UiError { pub human: Human, pub technical: String, + pub fields: Vec, } impl From for UiError { fn from(e: Error) -> Self { - UiError { human: e.code().human(), technical: e.to_string() } + let fields = match &e { + Error::Validation(f) => f.clone(), + _ => Vec::new(), + }; + UiError { human: e.code().human(), technical: e.to_string(), fields } } } diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index 4b7f0cc..0e06d35 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod config; pub mod driver; pub mod error; pub mod fsx; diff --git a/schemas/bots.json b/schemas/bots.json new file mode 100644 index 0000000..6fd07ad --- /dev/null +++ b/schemas/bots.json @@ -0,0 +1,976 @@ +{ + "schema": 1, + "scope": "bots", + "categories": [ + { + "id": "general", + "title": "General" + }, + { + "id": "population", + "title": "Population" + }, + { + "id": "login", + "title": "Login" + }, + { + "id": "roles", + "title": "Roles" + }, + { + "id": "openworld", + "title": "Open World" + }, + { + "id": "quests", + "title": "Quests" + }, + { + "id": "combat", + "title": "Combat" + }, + { + "id": "dungeon", + "title": "Dungeon Finder" + }, + { + "id": "battlegrounds", + "title": "Battlegrounds" + }, + { + "id": "social", + "title": "Social Behavior" + }, + { + "id": "advanced", + "title": "Advanced" + } + ], + "settings": [ + { + "key": "CoaBots.RandomSpawn.DefaultCount", + "type": "int", + "category": "population", + "title": "Bots created per spawn command", + "description": "How many bots the spawn command creates when you do not give a number.", + "default": 10, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 1, + "max": 1000 + }, + { + "key": "CoaBots.RandomSpawn.MaxCount", + "type": "int", + "category": "population", + "title": "Largest single spawn", + "description": "Safety limit for one spawn command. Very large batches can freeze the server for a moment, so raise this only when nobody else is playing.", + "default": 1000, + "advanced": true, + "restartRequired": "world", + "dangerous": true, + "min": 1, + "max": 5000 + }, + { + "key": "CoaBots.LeveledSpawn.Brackets", + "type": "string", + "category": "population", + "title": "Level mix of new bots", + "description": "How new levelling bots are spread over levels, as ranges with weights (1-20:40 means 40 parts of bots between level 1 and 20).", + "default": "1-20:40,21-60:33,61-79:20,80-80:7", + "advanced": true, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "CoaBots.RandomSpawn.AutoLogin", + "type": "bool", + "category": "population", + "title": "Bring new bots online immediately", + "description": "Newly created bots log in right away instead of waiting in the database.", + "default": true, + "advanced": false, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "CoaBots.RandomSpawn.AccountPrefix", + "type": "string", + "category": "population", + "title": "Bot account name prefix", + "description": "Bots live on accounts that start with this name. Changing it makes existing bots stop logging in automatically.", + "default": "CoaBotHost", + "advanced": true, + "restartRequired": "world", + "dangerous": true + }, + { + "key": "CoaBots.RandomSpawn.BatchSize", + "type": "int", + "category": "population", + "title": "Bots created per step", + "description": "Creation is spread over time to keep the server smooth. Higher values are faster but can cause freezes.", + "default": 5, + "advanced": true, + "restartRequired": "world", + "dangerous": true, + "min": 1, + "max": 50 + }, + { + "key": "CoaBots.RandomSpawn.BatchIntervalMs", + "type": "int", + "category": "population", + "title": "Pause between creation steps", + "description": "Time between creation steps. Lower values are faster but can cause freezes.", + "default": 500, + "advanced": true, + "restartRequired": "world", + "dangerous": true, + "min": 100, + "max": 60000, + "unit": "ms" + }, + { + "key": "CoaBots.AutoLoginOnStartup", + "type": "bool", + "category": "login", + "title": "Automatic bot login", + "description": "Existing bots log themselves in every time the server starts.", + "default": false, + "advanced": false, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "CoaBots.AutoLogin.MaxCount", + "type": "int", + "category": "login", + "title": "Maximum bots online", + "description": "The most bots that log in automatically at startup.", + "default": 800, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 5000 + }, + { + "key": "CoaBots.AutoLogin.BatchSize", + "type": "int", + "category": "login", + "title": "Bots logging in per step", + "description": "Logins are spread over time. Higher values bring bots online faster but load the server harder.", + "default": 10, + "advanced": true, + "restartRequired": "world", + "dangerous": true, + "min": 1, + "max": 100 + }, + { + "key": "CoaBots.AutoLogin.BatchIntervalMs", + "type": "int", + "category": "login", + "title": "Pause between login steps", + "description": "Time between login steps.", + "default": 500, + "advanced": true, + "restartRequired": "world", + "dangerous": true, + "min": 100, + "max": 60000, + "unit": "ms" + }, + { + "key": "CoaBots.SpecBalance.TankTargetPct", + "type": "int", + "category": "roles", + "title": "Tank population target", + "description": "Share of bots that should be tanks.", + "default": 20, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 50, + "unit": "%" + }, + { + "key": "CoaBots.SpecBalance.HealerTargetPct", + "type": "int", + "category": "roles", + "title": "Healer population target", + "description": "Share of bots that should be healers.", + "default": 20, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 50, + "unit": "%" + }, + { + "key": "CoaBots.TalentBuildsPath", + "type": "string", + "category": "general", + "title": "Talent data file", + "description": "Where the community talent builds are stored. Leave empty to use the built-in search.", + "default": "", + "advanced": true, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "CoaBots.World.Enable", + "type": "bool", + "category": "openworld", + "title": "Bots wander the world", + "description": "Idle bots run errands: visit town services, travel to gathering or grinding areas, or wander a little.", + "default": true, + "advanced": false, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "CoaBots.WorldBrain.Enable", + "type": "bool", + "category": "quests", + "title": "Questing AI", + "description": "Bots pick up and complete quests on their own. When off, bots only gather, fish, grind and wander.", + "default": true, + "advanced": false, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "CoaBots.World.ServiceRadius", + "type": "int", + "category": "openworld", + "title": "Town services search radius", + "description": "How far a bot looks for vendors and mailboxes.", + "default": 200, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 50, + "max": 1000, + "unit": "yards" + }, + { + "key": "CoaBots.World.AreaRadius", + "type": "int", + "category": "openworld", + "title": "Errand area radius", + "description": "How far a bot looks for gathering or grinding areas.", + "default": 350, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 50, + "max": 2000, + "unit": "yards" + }, + { + "key": "CoaBots.World.ThinkIntervalMs", + "type": "int", + "category": "advanced", + "title": "Errand check interval", + "description": "How often each bot reconsiders its errand.", + "default": 2000, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 500, + "max": 60000, + "unit": "ms" + }, + { + "key": "CoaBots.World.IdleBeforeErrandMs", + "type": "int", + "category": "advanced", + "title": "Idle time before an errand", + "description": "How long a bot must find nothing to do before it starts an errand.", + "default": 10000, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 1000, + "max": 600000, + "unit": "ms" + }, + { + "key": "CoaBots.World.VerboseLog", + "type": "bool", + "category": "advanced", + "title": "Detailed errand logging", + "description": "Writes every errand step to the log. Useful for debugging, noisy with many bots.", + "default": true, + "advanced": true, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "CoaBots.Combat.VerboseLog", + "type": "bool", + "category": "advanced", + "title": "Detailed combat logging", + "description": "Writes combat decisions to the log. Very noisy; turn on only while hunting a specific problem.", + "default": false, + "advanced": true, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "CoaBots.Profile.IntentMinMs", + "type": "int", + "category": "advanced", + "title": "Shortest activity", + "description": "The shortest time a bot sticks with one kind of activity.", + "default": 900000, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 60000, + "max": 7200000, + "unit": "ms" + }, + { + "key": "CoaBots.Profile.IntentMaxMs", + "type": "int", + "category": "advanced", + "title": "Longest activity", + "description": "The longest time a bot sticks with one kind of activity.", + "default": 2100000, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 60000, + "max": 14400000, + "unit": "ms" + }, + { + "key": "CoaBots.Grind.SearchRadius", + "type": "int", + "category": "openworld", + "title": "Grinding search radius", + "description": "How far a bot looks for monsters to grind.", + "default": 30, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 5, + "max": 200, + "unit": "yards" + }, + { + "key": "CoaBots.Grind.MaxLevelAbove", + "type": "int", + "category": "openworld", + "title": "Highest monster level to grind", + "description": "How many levels above itself a bot will fight while grinding.", + "default": 3, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 10, + "unit": "levels" + }, + { + "key": "CoaBots.Healer.CriticalHealPct", + "type": "int", + "category": "combat", + "title": "Emergency heal threshold", + "description": "Healers switch to their strongest heals when an ally falls below this health.", + "default": 50, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 5, + "max": 95, + "unit": "%" + }, + { + "key": "CoaBots.WorldBrain.SearchRadius", + "type": "int", + "category": "openworld", + "title": "Target search radius", + "description": "How far a bot scans for quest targets inside an objective area.", + "default": 50, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 10, + "max": 300, + "unit": "yards" + }, + { + "key": "CoaBots.WorldBrain.EngageRange", + "type": "int", + "category": "combat", + "title": "Engage distance", + "description": "Distance at which a bot starts fighting a quest monster.", + "default": 28, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 5, + "max": 40, + "unit": "yards" + }, + { + "key": "CoaBots.WorldBrain.MountDistanceMin", + "type": "int", + "category": "openworld", + "title": "Mount up beyond (min)", + "description": "Each bot mounts for trips longer than a personal distance drawn between this and the maximum.", + "default": 60, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 10, + "max": 500, + "unit": "yards" + }, + { + "key": "CoaBots.WorldBrain.MountDistanceMax", + "type": "int", + "category": "openworld", + "title": "Mount up beyond (max)", + "description": "Upper end of the personal mount distance.", + "default": 95, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 10, + "max": 500, + "unit": "yards" + }, + { + "key": "CoaBots.WorldBrain.TaxiMinDistance", + "type": "int", + "category": "openworld", + "title": "Use flight paths beyond", + "description": "Trips longer than this use a flight path when the bot knows a route.", + "default": 900, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 100, + "max": 10000, + "unit": "yards" + }, + { + "key": "CoaBots.WorldBrain.SessionBreaks", + "type": "bool", + "category": "openworld", + "title": "Take breaks", + "description": "Bots alternate questing with a few minutes of town life, like a player would.", + "default": true, + "advanced": false, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "CoaBots.WorldBrain.GatherDetours", + "type": "bool", + "category": "openworld", + "title": "Gather nearby herbs and ore", + "description": "Gatherers step off their path for a node right next to it.", + "default": true, + "advanced": false, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "CoaBots.WorldBrain.MaxActiveQuests", + "type": "int", + "category": "quests", + "title": "Maximum active quests", + "description": "How many quests a bot carries at once.", + "default": 15, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 1, + "max": 25 + }, + { + "key": "CoaBots.WorldBrain.AcceptEliteQuests", + "type": "bool", + "category": "quests", + "title": "Accept elite quests", + "description": "Solo bots take on elite quests. Off by default because they usually cannot finish them alone.", + "default": false, + "advanced": false, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "CoaBots.WorldBrain.QuestLevelBelow", + "type": "int", + "category": "quests", + "title": "Quests below own level", + "description": "How many levels below itself a bot still accepts quests.", + "default": 6, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 20, + "unit": "levels" + }, + { + "key": "CoaBots.WorldBrain.QuestLevelAbove", + "type": "int", + "category": "quests", + "title": "Quests above own level", + "description": "How many levels above itself a bot still accepts quests.", + "default": 3, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 10, + "unit": "levels" + }, + { + "key": "CoaBots.WorldBrain.MaxMobLevelAbove", + "type": "int", + "category": "quests", + "title": "Highest quest monster level", + "description": "How far above its level a quest monster may be.", + "default": 3, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 10, + "unit": "levels" + }, + { + "key": "CoaBots.WorldBrain.MaxObjectiveDistance", + "type": "int", + "category": "quests", + "title": "Farthest quest objective", + "description": "Quests whose objectives are farther than this are not taken.", + "default": 3000, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 200, + "max": 10000, + "unit": "yards" + }, + { + "key": "CoaBots.WorldBrain.GiverSearchRadius", + "type": "int", + "category": "quests", + "title": "Quest giver search radius", + "description": "How far a bot looks for quest givers.", + "default": 500, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 50, + "max": 3000, + "unit": "yards" + }, + { + "key": "CoaBots.WorldBrain.MaxBadAreasPerObjective", + "type": "int", + "category": "quests", + "title": "Areas tried per objective", + "description": "How many areas a bot gives up on before setting the quest aside.", + "default": 3, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 1, + "max": 10 + }, + { + "key": "CoaBots.WorldBrain.QuestSuspendMs", + "type": "int", + "category": "quests", + "title": "Set-aside time (short)", + "description": "How long a quest is set aside after the bot gives up on it.", + "default": 600000, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 60000, + "max": 86400000, + "unit": "ms" + }, + { + "key": "CoaBots.WorldBrain.QuestSuspendMaxMs", + "type": "int", + "category": "quests", + "title": "Set-aside time (long)", + "description": "The longest a quest can stay set aside after repeated failures.", + "default": 7200000, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 60000, + "max": 86400000, + "unit": "ms" + }, + { + "key": "CoaBots.LfgFill.Enable", + "type": "bool", + "category": "dungeon", + "title": "Dungeon Finder bot fill", + "description": "When you queue alone for a dungeon, bots fill the missing tank, healer and damage roles.", + "default": true, + "advanced": false, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "CoaBots.AutoQueueDungeon.Enable", + "type": "bool", + "category": "dungeon", + "title": "Bots queue for dungeons by themselves", + "description": "Experimental: idle tank bots queue for a dungeon on their own and the fill above completes the party.", + "default": false, + "advanced": true, + "restartRequired": "world", + "dangerous": true + }, + { + "key": "CoaBots.BGFill.Enable", + "type": "bool", + "category": "battlegrounds", + "title": "Battleground bot fill", + "description": "When anyone queues for a battleground, both teams are topped up with bots so the match starts at once.", + "default": true, + "advanced": false, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "CoaBots.BGFill.TargetPlayersPerTeam", + "type": "int", + "category": "battlegrounds", + "title": "Battleground team size", + "description": "Bots per team to fill up to. 0 means a full team for that battleground.", + "default": 0, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 40 + }, + { + "key": "CoaBots.WorldBrain.HelpOthers", + "type": "bool", + "category": "social", + "title": "Bots help nearby players", + "description": "A passing bot joins a fight that a friendly player is losing, without taking their loot or credit.", + "default": true, + "advanced": false, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "CoaBots.WorldBrain.HelpRadius", + "type": "int", + "category": "social", + "title": "Help distance", + "description": "How close a bot must be to step into a fight.", + "default": 30, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 5, + "max": 100, + "unit": "yards" + }, + { + "key": "CoaBots.WorldBrain.HelpHealthPct", + "type": "int", + "category": "social", + "title": "Help below health", + "description": "Bots help when the fighting player is below this health.", + "default": 40, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 5, + "max": 95, + "unit": "%" + }, + { + "key": "CoaBots.WorldBrain.ResurrectOthers", + "type": "bool", + "category": "social", + "title": "Bots resurrect fallen players", + "description": "Bots that know a resurrection spell revive friendly corpses.", + "default": true, + "advanced": false, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "CoaBots.WorldBrain.TemporaryParties", + "type": "bool", + "category": "social", + "title": "Temporary bot parties", + "description": "Bots of similar level with the same quest briefly team up.", + "default": false, + "advanced": false, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "CoaBots.WorldBrain.PartyMaxSize", + "type": "int", + "category": "social", + "title": "Temporary party size", + "description": "Largest temporary party.", + "default": 3, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 2, + "max": 5 + }, + { + "key": "CoaBots.WorldBrain.PartyRadius", + "type": "int", + "category": "social", + "title": "Party formation distance", + "description": "How close bots must be to team up.", + "default": 60, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 10, + "max": 300, + "unit": "yards" + }, + { + "key": "CoaBots.WorldBrain.PartyMinMinutes", + "type": "int", + "category": "social", + "title": "Shortest party time", + "description": "Minimum duration of a temporary party.", + "default": 5, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 1, + "max": 120, + "unit": "min" + }, + { + "key": "CoaBots.WorldBrain.PartyMaxMinutes", + "type": "int", + "category": "social", + "title": "Longest party time", + "description": "Maximum duration of a temporary party.", + "default": 20, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 1, + "max": 240, + "unit": "min" + }, + { + "key": "CoaBots.WorldBrain.PlannerMinMs", + "type": "int", + "category": "advanced", + "title": "Planner interval (min)", + "description": "Shortest time between a bot's planning passes.", + "default": 2000, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 500, + "max": 60000, + "unit": "ms" + }, + { + "key": "CoaBots.WorldBrain.PlannerMaxMs", + "type": "int", + "category": "advanced", + "title": "Planner interval (max)", + "description": "Longest time between a bot's planning passes.", + "default": 8000, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 500, + "max": 120000, + "unit": "ms" + }, + { + "key": "CoaBots.WorldBrain.ScanMinMs", + "type": "int", + "category": "advanced", + "title": "Target scan interval (min)", + "description": "Shortest time between target scans.", + "default": 500, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 100, + "max": 10000, + "unit": "ms" + }, + { + "key": "CoaBots.WorldBrain.ScanMaxMs", + "type": "int", + "category": "advanced", + "title": "Target scan interval (max)", + "description": "Longest time between target scans.", + "default": 1500, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 100, + "max": 20000, + "unit": "ms" + }, + { + "key": "CoaBots.WorldBrain.SearchTimeoutMs", + "type": "int", + "category": "advanced", + "title": "Area search time limit", + "description": "How long a bot searches an empty area before trying another.", + "default": 35000, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 5000, + "max": 600000, + "unit": "ms" + }, + { + "key": "CoaBots.WorldBrain.ApproachTimeoutMs", + "type": "int", + "category": "advanced", + "title": "Approach time limit", + "description": "How long a bot walks toward a claimed target.", + "default": 25000, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 5000, + "max": 600000, + "unit": "ms" + }, + { + "key": "CoaBots.WorldBrain.TravelTimeoutMs", + "type": "int", + "category": "advanced", + "title": "Travel time limit", + "description": "How long a whole trip to an area may take.", + "default": 360000, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 30000, + "max": 3600000, + "unit": "ms" + }, + { + "key": "CoaBots.WorldBrain.Weight.TravelPer100Yards", + "type": "int", + "category": "advanced", + "title": "Preference weight: TravelPer100Yards", + "description": "How strongly bots weigh this factor when choosing what to do next. Change only if you know the planner.", + "default": 7, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 1000 + }, + { + "key": "CoaBots.WorldBrain.Weight.CrowdPerBot", + "type": "int", + "category": "advanced", + "title": "Preference weight: CrowdPerBot", + "description": "How strongly bots weigh this factor when choosing what to do next. Change only if you know the planner.", + "default": 6, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 1000 + }, + { + "key": "CoaBots.WorldBrain.Weight.Overlap", + "type": "int", + "category": "advanced", + "title": "Preference weight: Overlap", + "description": "How strongly bots weigh this factor when choosing what to do next. Change only if you know the planner.", + "default": 35, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 1000 + }, + { + "key": "CoaBots.WorldBrain.Weight.TurnIn", + "type": "int", + "category": "advanced", + "title": "Preference weight: TurnIn", + "description": "How strongly bots weigh this factor when choosing what to do next. Change only if you know the planner.", + "default": 120, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 1000 + }, + { + "key": "CoaBots.WorldBrain.Weight.Accept", + "type": "int", + "category": "advanced", + "title": "Preference weight: Accept", + "description": "How strongly bots weigh this factor when choosing what to do next. Change only if you know the planner.", + "default": 55, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 1000 + }, + { + "key": "CoaBots.WorldBrain.Weight.AreaOccupancy", + "type": "int", + "category": "advanced", + "title": "Preference weight: AreaOccupancy", + "description": "How strongly bots weigh this factor when choosing what to do next. Change only if you know the planner.", + "default": 12, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 1000 + }, + { + "key": "CoaBots.WorldBrain.Weight.TargetReserved", + "type": "int", + "category": "advanced", + "title": "Preference weight: TargetReserved", + "description": "How strongly bots weigh this factor when choosing what to do next. Change only if you know the planner.", + "default": 200, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 1000 + } + ] +} diff --git a/schemas/presets/bots.json b/schemas/presets/bots.json new file mode 100644 index 0000000..3704102 --- /dev/null +++ b/schemas/presets/bots.json @@ -0,0 +1,67 @@ +{ + "schema": 1, + "presets": [ + { + "id": "balanced", + "title": "Balanced", + "description": "The recommended everyday setup: questing, dungeon and battleground fill on, quiet logs.", + "values": { + "CoaBots.World.VerboseLog": false, + "CoaBots.Combat.VerboseLog": false, + "CoaBots.AutoLoginOnStartup": true, + "CoaBots.AutoLogin.MaxCount": 300, + "CoaBots.WorldBrain.Enable": true, + "CoaBots.LfgFill.Enable": true, + "CoaBots.BGFill.Enable": true, + "CoaBots.WorldBrain.TemporaryParties": false + } + }, + { + "id": "solo-friendly", + "title": "Solo Friendly", + "description": "Bots that help you and fill your groups, with a small quiet population.", + "values": { + "CoaBots.World.VerboseLog": false, + "CoaBots.Combat.VerboseLog": false, + "CoaBots.AutoLoginOnStartup": true, + "CoaBots.AutoLogin.MaxCount": 100, + "CoaBots.WorldBrain.HelpOthers": true, + "CoaBots.WorldBrain.ResurrectOthers": true, + "CoaBots.LfgFill.Enable": true, + "CoaBots.BGFill.Enable": true, + "CoaBots.WorldBrain.TemporaryParties": false + } + }, + { + "id": "living-world", + "title": "Living World", + "description": "A busier world: more bots, breaks, gathering detours and temporary parties.", + "values": { + "CoaBots.World.VerboseLog": false, + "CoaBots.Combat.VerboseLog": false, + "CoaBots.AutoLoginOnStartup": true, + "CoaBots.AutoLogin.MaxCount": 500, + "CoaBots.World.Enable": true, + "CoaBots.WorldBrain.Enable": true, + "CoaBots.WorldBrain.SessionBreaks": true, + "CoaBots.WorldBrain.GatherDetours": true, + "CoaBots.WorldBrain.TemporaryParties": true + } + }, + { + "id": "performance", + "title": "Performance", + "description": "Fewer bots and less bot thinking, for weaker computers.", + "values": { + "CoaBots.World.VerboseLog": false, + "CoaBots.Combat.VerboseLog": false, + "CoaBots.AutoLoginOnStartup": true, + "CoaBots.AutoLogin.MaxCount": 50, + "CoaBots.WorldBrain.TemporaryParties": false, + "CoaBots.WorldBrain.GatherDetours": false, + "CoaBots.WorldBrain.SessionBreaks": false, + "CoaBots.WorldBrain.MaxActiveQuests": 8 + } + } + ] +} diff --git a/schemas/presets/server.json b/schemas/presets/server.json new file mode 100644 index 0000000..5bca4c5 --- /dev/null +++ b/schemas/presets/server.json @@ -0,0 +1,47 @@ +{ + "schema": 1, + "presets": [ + { + "id": "vanilla", + "title": "Standard rates", + "description": "Everything at normal (x1) speed.", + "values": { + "Rate.XP.Kill": 1, + "Rate.XP.Quest": 1, + "Rate.XP.Explore": 1, + "Rate.Drop.Item.Normal": 1, + "Rate.Drop.Money": 1, + "Rate.Honor": 1, + "Rate.Reputation.Gain": 1 + } + }, + { + "id": "relaxed", + "title": "Relaxed", + "description": "Faster levelling and more drops for casual play (x2 / x3).", + "values": { + "Rate.XP.Kill": 2, + "Rate.XP.Quest": 2, + "Rate.XP.Explore": 2, + "Rate.Drop.Item.Normal": 2, + "Rate.Drop.Money": 3, + "Rate.Honor": 2, + "Rate.Reputation.Gain": 2 + } + }, + { + "id": "fast", + "title": "Fast", + "description": "Quick progression for testing or short sessions (x5).", + "values": { + "Rate.XP.Kill": 5, + "Rate.XP.Quest": 5, + "Rate.XP.Explore": 5, + "Rate.Drop.Item.Normal": 3, + "Rate.Drop.Money": 5, + "Rate.Honor": 3, + "Rate.Reputation.Gain": 3 + } + } + ] +} diff --git a/schemas/server.json b/schemas/server.json new file mode 100644 index 0000000..cb3072b --- /dev/null +++ b/schemas/server.json @@ -0,0 +1,568 @@ +{ + "schema": 1, + "scope": "server", + "categories": [ + { + "id": "gameplay", + "title": "Gameplay" + }, + { + "id": "rates", + "title": "Rates" + }, + { + "id": "characters", + "title": "Characters" + }, + { + "id": "world", + "title": "World" + }, + { + "id": "network", + "title": "Network" + }, + { + "id": "accounts", + "title": "Accounts" + }, + { + "id": "logging", + "title": "Logging" + }, + { + "id": "performance", + "title": "Performance" + }, + { + "id": "advanced", + "title": "Advanced" + } + ], + "settings": [ + { + "key": "Rate.XP.Kill", + "type": "float", + "category": "rates", + "title": "Experience from kills", + "description": "Multiplier for experience gained by killing monsters. 1 is normal.", + "default": 1, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 20, + "unit": "x" + }, + { + "key": "Rate.XP.Quest", + "type": "float", + "category": "rates", + "title": "Experience from quests", + "description": "Multiplier for experience gained from quests.", + "default": 1, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 20, + "unit": "x" + }, + { + "key": "Rate.XP.Quest.DF", + "type": "float", + "category": "rates", + "title": "Experience from daily / dungeon-finder quests", + "description": "Multiplier for experience from Dungeon Finder quests.", + "default": 1, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 20, + "unit": "x" + }, + { + "key": "Rate.XP.Explore", + "type": "float", + "category": "rates", + "title": "Experience from exploring", + "description": "Multiplier for experience from discovering new areas.", + "default": 1, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 20, + "unit": "x" + }, + { + "key": "Rate.Drop.Item.Normal", + "type": "float", + "category": "rates", + "title": "Item drop rate", + "description": "Multiplier for how often normal-quality items drop.", + "default": 1, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 20, + "unit": "x" + }, + { + "key": "Rate.Drop.Money", + "type": "float", + "category": "rates", + "title": "Gold drop rate", + "description": "Multiplier for gold dropped by monsters.", + "default": 1, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 20, + "unit": "x" + }, + { + "key": "Rate.Honor", + "type": "float", + "category": "rates", + "title": "Honor gain", + "description": "Multiplier for honor points earned.", + "default": 1, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 20, + "unit": "x" + }, + { + "key": "Rate.Reputation.Gain", + "type": "float", + "category": "rates", + "title": "Reputation gain", + "description": "Multiplier for reputation earned.", + "default": 1, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 20, + "unit": "x" + }, + { + "key": "Rate.Rest.InGame", + "type": "float", + "category": "rates", + "title": "Rested bonus while playing", + "description": "Speed at which rested experience is used up while you play.", + "default": 1, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 10, + "unit": "x" + }, + { + "key": "Rate.Creature.Normal.Damage", + "type": "float", + "category": "advanced", + "title": "Monster damage", + "description": "Multiplier for damage dealt by normal monsters. Changes difficulty a lot.", + "default": 1, + "advanced": true, + "restartRequired": "world", + "dangerous": true, + "min": 0.1, + "max": 10, + "unit": "x" + }, + { + "key": "Rate.Creature.Normal.HP", + "type": "float", + "category": "advanced", + "title": "Monster health", + "description": "Multiplier for health of normal monsters. Changes difficulty a lot.", + "default": 1, + "advanced": true, + "restartRequired": "world", + "dangerous": true, + "min": 0.1, + "max": 10, + "unit": "x" + }, + { + "key": "PlayerLimit", + "type": "int", + "category": "world", + "title": "Maximum players", + "description": "How many players can be online at once. 0 means no limit.", + "default": 0, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 10000 + }, + { + "key": "GameType", + "type": "enum", + "category": "advanced", + "title": "Realm type", + "description": "The realm type shown in the realm list.", + "default": 0, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "options": [ + { + "value": 0, + "label": "Normal" + }, + { + "value": 1, + "label": "Player vs Player" + }, + { + "value": 6, + "label": "Roleplay" + }, + { + "value": 8, + "label": "Roleplay + PvP" + } + ] + }, + { + "key": "RealmZone", + "type": "int", + "category": "advanced", + "title": "Realm zone", + "description": "The realm's language / region group shown to clients.", + "default": 1, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 30 + }, + { + "key": "PlayerSaveInterval", + "type": "int", + "category": "world", + "title": "Autosave interval", + "description": "How often player characters are saved while online.", + "default": 900000, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 60000, + "max": 3600000, + "unit": "ms" + }, + { + "key": "CharactersPerAccount", + "type": "int", + "category": "characters", + "title": "Characters per account", + "description": "Maximum characters an account can have across all realms.", + "default": 50, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 1, + "max": 200 + }, + { + "key": "CharactersPerRealm", + "type": "int", + "category": "characters", + "title": "Characters per realm", + "description": "Maximum characters an account can have on this realm.", + "default": 10, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 1, + "max": 50 + }, + { + "key": "StartPlayerLevel", + "type": "int", + "category": "characters", + "title": "Starting level", + "description": "Level new characters start at.", + "default": 1, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 1, + "max": 80 + }, + { + "key": "StartPlayerMoney", + "type": "int", + "category": "characters", + "title": "Starting gold", + "description": "Copper new characters start with (10000 copper = 1 gold).", + "default": 0, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 2147483647, + "unit": "copper" + }, + { + "key": "MaxPlayerLevel", + "type": "int", + "category": "characters", + "title": "Maximum level", + "description": "Highest level a character can reach. The game client only supports 80.", + "default": 80, + "advanced": true, + "restartRequired": "world", + "dangerous": true, + "min": 1, + "max": 80 + }, + { + "key": "SkipCinematics", + "type": "enum", + "category": "characters", + "title": "Skip intro cinematics", + "description": "Whether new characters see their intro movie.", + "default": 0, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "options": [ + { + "value": 0, + "label": "Show all" + }, + { + "value": 1, + "label": "Skip for non-first characters" + }, + { + "value": 2, + "label": "Skip all" + } + ] + }, + { + "key": "DurabilityLoss.OnDeath", + "type": "float", + "category": "gameplay", + "title": "Durability lost on death", + "description": "Percent of item durability lost when you die.", + "default": 10, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": 0, + "max": 100, + "unit": "%" + }, + { + "key": "Death.SicknessLevel", + "type": "int", + "category": "gameplay", + "title": "Resurrection sickness up to level", + "description": "Characters above this level get resurrection sickness. -10 turns it off.", + "default": 11, + "advanced": false, + "restartRequired": "world", + "dangerous": false, + "min": -10, + "max": 80 + }, + { + "key": "LeaveGroupOnLogout.Enabled", + "type": "bool", + "category": "gameplay", + "title": "Leave group on logout", + "description": "Players are removed from their group when they log out.", + "default": false, + "advanced": true, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "GM.LoginState", + "type": "enum", + "category": "gameplay", + "title": "GM mode at login", + "description": "Whether game masters start with GM mode on.", + "default": 2, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "options": [ + { + "value": 0, + "label": "Off" + }, + { + "value": 1, + "label": "On" + }, + { + "value": 2, + "label": "Same as last time" + } + ] + }, + { + "key": "AllowTwoSide.Accounts", + "type": "bool", + "category": "accounts", + "title": "Both factions on one account", + "description": "One account may have Alliance and Horde characters.", + "default": true, + "advanced": false, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "AllowTwoSide.Interaction.Chat", + "type": "bool", + "category": "accounts", + "title": "Cross-faction chat", + "description": "Alliance and Horde players can talk to each other.", + "default": false, + "advanced": false, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "AutoBroadcast.On", + "type": "bool", + "category": "world", + "title": "Automatic announcements", + "description": "Broadcast the messages stored in the database at regular intervals.", + "default": false, + "advanced": true, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "AutoBroadcast.Timer", + "type": "int", + "category": "world", + "title": "Announcement interval", + "description": "Time between automatic announcements.", + "default": 60000, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 5000, + "max": 3600000, + "unit": "ms" + }, + { + "key": "Warden.Enabled", + "type": "bool", + "category": "advanced", + "title": "Anti-cheat (Warden)", + "description": "Enables the client anti-cheat check. Usually unnecessary on a private server.", + "default": true, + "advanced": true, + "restartRequired": "world", + "dangerous": false + }, + { + "key": "MapUpdateInterval", + "type": "int", + "category": "performance", + "title": "Map update interval", + "description": "How often the world updates maps. Lower is smoother but uses more CPU.", + "default": 10, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 1, + "max": 1000, + "unit": "ms" + }, + { + "key": "MinWorldUpdateTime", + "type": "int", + "category": "performance", + "title": "Minimum world update time", + "description": "Shortest time between world updates. Raise it to reduce CPU use.", + "default": 1, + "advanced": true, + "restartRequired": "world", + "dangerous": false, + "min": 1, + "max": 1000, + "unit": "ms" + }, + { + "key": "ThreadPool", + "type": "int", + "category": "performance", + "title": "Worker threads", + "description": "Number of background worker threads. More helps large populations.", + "default": 2, + "advanced": true, + "restartRequired": "full", + "dangerous": false, + "min": 1, + "max": 32 + }, + { + "key": "Network.Threads", + "type": "int", + "category": "network", + "title": "Network threads", + "description": "Threads that handle player connections. One is enough for small servers.", + "default": 1, + "advanced": true, + "restartRequired": "full", + "dangerous": false, + "min": 1, + "max": 16 + }, + { + "key": "Ra.Enable", + "type": "bool", + "category": "advanced", + "title": "Remote console", + "description": "Lets the Manager and scripts send commands to the server. It only listens on this computer; keep it that way.", + "default": true, + "advanced": true, + "restartRequired": "full", + "dangerous": true + }, + { + "key": "Ra.IP", + "type": "string", + "category": "advanced", + "title": "Remote console address", + "description": "Address the remote console listens on. Keep 127.0.0.1 so it is not reachable from the internet.", + "default": "127.0.0.1", + "advanced": true, + "restartRequired": "full", + "dangerous": true + }, + { + "key": "Updates.EnableDatabases", + "type": "int", + "category": "advanced", + "title": "Automatic database updates", + "description": "Lets the server change its own databases at start. Keep 0: the Manager applies and tracks database updates safely.", + "default": 0, + "advanced": true, + "restartRequired": "full", + "dangerous": true, + "min": 0, + "max": 7 + } + ] +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cc159cd..8493ba9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ use std::collections::HashSet; use std::path::PathBuf; use std::sync::Mutex; +use coa_core::config::{self, Scope, SettingsView}; use coa_core::driver::{self, DriverOutcome, Verb}; use coa_core::error::UiError; use coa_core::layout::{self, Classification, ScanReport}; @@ -12,6 +13,8 @@ use coa_core::process::{self, Observed}; use coa_core::registry::{metadata_dir_for, InstallKind, InstallMeta, MetaDir, Registry}; use coa_core::{Error, Result}; use serde::Serialize; +use serde_json::Value; +use std::collections::BTreeMap; use tauri::State; struct AppState { @@ -161,6 +164,78 @@ async fn stop_server(state: State<'_, AppState>, id: String) -> std::result::Res run_verb(&state, id, Verb::StopAll).await } + +#[derive(Serialize)] +struct PresetInfo { + id: String, + title: String, + description: String, +} + +async fn blocking(f: impl FnOnce() -> Result + Send + 'static) -> std::result::Result { + tauri::async_runtime::spawn_blocking(f) + .await + .map_err(|e| Error::Invalid(e.to_string()))? + .map_err(Into::into) +} + +fn meta_dir(root: &std::path::Path) -> Result { + let dir = metadata_dir_for(root)?; + if dir.join("install.json").is_file() { + Ok(dir) + } else { + Err(Error::Invalid("This server has not been added to the Manager yet.".into())) + } +} + +#[tauri::command] +async fn get_settings(state: State<'_, AppState>, id: String, scope: Scope) -> std::result::Result { + let root = path_of(&state, &id)?; + blocking(move || config::load(&root, scope)).await +} + +#[tauri::command] +async fn save_settings( + state: State<'_, AppState>, + id: String, + scope: Scope, + changes: BTreeMap, +) -> std::result::Result { + let root = path_of(&state, &id)?; + blocking(move || { + let meta = meta_dir(&root)?; + config::save(&root, &meta, scope, &changes) + }) + .await +} + +#[tauri::command] +fn list_presets(scope: Scope) -> Vec { + scope + .presets() + .iter() + .map(|p| PresetInfo { id: p.id.clone(), title: p.title.clone(), description: p.description.clone() }) + .collect() +} + +#[tauri::command] +async fn preview_preset(state: State<'_, AppState>, id: String, scope: Scope, preset: String) -> std::result::Result { + let root = path_of(&state, &id)?; + blocking(move || if preset == "defaults" { config::preview_defaults(&root, scope) } else { config::preview_preset(&root, scope, &preset) }).await +} + +#[tauri::command] +fn list_config_snapshots(state: State<'_, AppState>, id: String) -> std::result::Result, UiError> { + let root = path_of(&state, &id)?; + Ok(config::list_snapshots(&meta_dir(&root)?)) +} + +#[tauri::command] +async fn restore_config_snapshot(state: State<'_, AppState>, id: String, snapshot: String) -> std::result::Result<(), UiError> { + let root = path_of(&state, &id)?; + blocking(move || config::restore_snapshot(&meta_dir(&root)?, &snapshot)).await +} + pub fn run() { let dir = data_dir(); let _ = coa_core::logging::init(&dir.join("logs").join("manager.log")); @@ -175,7 +250,13 @@ pub fn run() { forget_server, server_status, start_server, - stop_server + stop_server, + get_settings, + save_settings, + list_presets, + preview_preset, + list_config_snapshots, + restore_config_snapshot ]) .run(tauri::generate_context!()) .expect("error while running CoA Server Manager"); diff --git a/src/lib/api.ts b/src/lib/api.ts index e70736d..8b67a2c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -68,9 +68,79 @@ export interface ServerSummary { path: string; } +export interface FieldError { + key: string; + message: string; +} + export interface UiError { human: Human; technical: string; + fields?: FieldError[]; +} + +export type Scope = "bots" | "server"; +export type Restart = "runtime" | "world" | "full"; +export type JsonValue = string | number | boolean; + +export interface Setting { + key: string; + type: "bool" | "int" | "float" | "string" | "enum"; + category: string; + title: string; + description: string; + default: JsonValue; + min?: number; + max?: number; + options: { value: JsonValue; label: string }[]; + unit?: string | null; + advanced: boolean; + restartRequired: Restart; + dangerous: boolean; +} + +export interface SettingView extends Setting { + value: JsonValue; + is_default: boolean; + present: boolean; + problem: string | null; + drift: boolean; +} + +export interface SettingsView { + scope: Scope; + categories: { id: string; title: string }[]; + settings: SettingView[]; + unknown_keys: number; + drift_keys: string[]; + files: string[]; +} + +export interface PresetInfo { + id: string; + title: string; + description: string; +} + +export interface PresetChange { + key: string; + title: string; + from: JsonValue; + to: JsonValue; + dangerous: boolean; +} + +export interface PresetPreview { + id: string; + title: string; + description: string; + changes: PresetChange[]; +} + +export interface SaveReport { + changed: { key: string; title: string; restart: Restart; dangerous: boolean }[]; + restart: Restart | null; + snapshot: string | null; } export const api = { @@ -82,6 +152,10 @@ export const api = { status: (id: string) => invoke("server_status", { id }), start: (id: string) => invoke("start_server", { id }), stop: (id: string) => invoke("stop_server", { id }), + settings: (id: string, scope: Scope) => invoke("get_settings", { id, scope }), + save: (id: string, scope: Scope, changes: Record) => invoke("save_settings", { id, scope, changes }), + presets: (scope: Scope) => invoke("list_presets", { scope }), + previewPreset: (id: string, scope: Scope, preset: string) => invoke("preview_preset", { id, scope, preset }), }; export function asUiError(e: unknown): UiError { diff --git a/src/screens/SettingsPage.tsx b/src/screens/SettingsPage.tsx new file mode 100644 index 0000000..fd81603 --- /dev/null +++ b/src/screens/SettingsPage.tsx @@ -0,0 +1,424 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { AlertTriangle, ChevronDown, ChevronRight, RotateCcw } from "lucide-react"; +import { + api, + asUiError, + type FieldError, + type JsonValue, + type PresetInfo, + type PresetPreview, + type SaveReport, + type Scope, + type SettingsView, + type SettingView, + type UiError, +} from "@/lib/api"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; + +const RESTART_LABEL = { runtime: "Applies immediately", world: "Needs a world server restart", full: "Needs a full server restart" } as const; + +function fmt(v: JsonValue, s: { type: string; unit?: string | null; options: { value: JsonValue; label: string }[] }): string { + if (typeof v === "boolean") return v ? "On" : "Off"; + if (s.type === "enum") return s.options.find((o) => o.value === v)?.label ?? String(v); + return s.unit ? `${v} ${s.unit}` : String(v); +} + +function clientCheck(s: SettingView, v: JsonValue): string | null { + if (s.type === "int" || s.type === "float") { + if (typeof v !== "number" || Number.isNaN(v)) return "Enter a number"; + if (s.type === "int" && !Number.isInteger(v)) return "Enter a whole number"; + if (s.min !== undefined && v < s.min) return `Must be at least ${s.min}`; + if (s.max !== undefined && v > s.max) return `Must be at most ${s.max}`; + } + return null; +} + +function Field(props: { s: SettingView; value: JsonValue; error: string | null; onChange: (v: JsonValue) => void }) { + const { s, value, error, onChange } = props; + 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") { + return ( + + ); + } + if (s.type === "enum") { + return ( + + ); + } + const numeric = s.type === "int" || s.type === "float"; + return ( +
+ { + const t = e.target.value; + onChange(numeric ? (t.trim() === "" || Number.isNaN(Number(t)) ? (t as unknown as number) : Number(t)) : t); + }} + /> + {s.unit && {s.unit}} +
+ ); +} + +function Row(props: { s: SettingView; value: JsonValue; error: string | null; onChange: (v: JsonValue) => void; onReset: () => void }) { + const { s, value, error } = props; + const changed = value !== s.value; + return ( +
+
+ +

{s.description}

+

+ Default: {fmt(s.default, s)} + {s.min !== undefined && s.max !== undefined && s.type !== "bool" ? ` · Range ${s.min}–${s.max}` : ""} · {RESTART_LABEL[s.restartRequired]} +

+ {s.problem &&

The file has an unusable value ({s.problem}); showing the default.

} + {s.drift &&

Edited by hand in the generated file — the launcher will reset it at next start unless you save it here.

} + {error && ( +

+ {error} +

+ )} +
+
+ + +
+
+ ); +} + +export function SettingsPage(props: { serverId: string; scope: Scope; title: string; question: string }) { + const { serverId, scope } = props; + const [view, setView] = useState(null); + const [draft, setDraft] = useState>({}); + const [errors, setErrors] = useState>({}); + const [fatal, setFatal] = useState(null); + const [saveErr, setSaveErr] = useState(null); + const [saved, setSaved] = useState(null); + const [presets, setPresets] = useState([]); + const [preview, setPreview] = useState(null); + const [category, setCategory] = useState(""); + const [showAdvanced, setShowAdvanced] = useState(false); + const [busy, setBusy] = useState(false); + const [running, setRunning] = useState(false); + + const load = useCallback(async () => { + try { + const v = await api.settings(serverId, scope); + setView(v); + setDraft({}); + setErrors({}); + setCategory((c) => c || v.categories.find((x) => v.settings.some((s) => s.category === x.id && !s.advanced))?.id || v.categories[0].id); + setFatal(null); + } catch (e) { + setFatal(asUiError(e)); + } + }, [serverId, scope]); + + useEffect(() => { + setView(null); + setSaved(null); + void load(); + void api.presets(scope).then(setPresets); + void api.status(serverId).then((s) => setRunning(s.observed.world.state === "running")); + }, [load, scope, serverId]); + + const valueOf = (s: SettingView): JsonValue => (s.key in draft ? draft[s.key] : s.value); + const dirtyKeys = useMemo(() => Object.keys(draft).filter((k) => view && draft[k] !== view.settings.find((s) => s.key === k)?.value), [draft, view]); + + if (fatal) { + return ( +
+

{props.title}

+ +

{fatal.human.code === "unknown" ? "Nothing to configure yet" : fatal.human.title}

+

{fatal.human.code === "unknown" ? fatal.technical : fatal.human.message}

+
+
+ ); + } + if (!view) return

Loading settings…

; + + const inCategory = view.settings.filter((s) => s.category === category); + const basic = inCategory.filter((s) => !s.advanced); + const advanced = inCategory.filter((s) => s.advanced); + const advancedCount = (id: string) => view.settings.filter((s) => s.category === id && s.advanced).length; + const basicCount = (id: string) => view.settings.filter((s) => s.category === id && !s.advanced).length; + + async function save(changes: Record) { + const local: Record = {}; + for (const [k, v] of Object.entries(changes)) { + const s = view!.settings.find((x) => x.key === k)!; + const m = clientCheck(s, v); + if (m) local[k] = m; + } + setErrors(local); + if (Object.keys(local).length) return; + const dangerous = Object.keys(changes).filter((k) => view!.settings.find((s) => s.key === k)?.dangerous); + if (dangerous.length && !window.confirm(`You are changing ${dangerous.length} setting(s) marked Careful. A snapshot of the current configuration is saved first, so this can be undone. Continue?`)) return; + setBusy(true); + setSaveErr(null); + try { + const report = await api.save(serverId, scope, changes); + setSaved(report); + setPreview(null); + await load(); + void api.status(serverId).then((s) => setRunning(s.observed.world.state === "running")); + } catch (e) { + const ui = asUiError(e); + setSaveErr(ui); + const map: Record = {}; + (ui.fields ?? ([] as FieldError[])).forEach((f) => (map[f.key] = f.message)); + setErrors(map); + } finally { + setBusy(false); + } + } + + async function openPreset(id: string) { + setSaved(null); + setPreview(await api.previewPreset(serverId, scope, id)); + } + + async function restartNow() { + setBusy(true); + try { + await api.stop(serverId); + await api.start(serverId); + setSaved(null); + setRunning(true); + } finally { + setBusy(false); + } + } + + return ( +
+

{props.title}

+

{props.question}

+ + {view.drift_keys.length > 0 && ( + +

{view.drift_keys.length} setting(s) were edited by hand

+

+ The server launcher rebuilds worldserver.conf from its template every time it starts, so these manual edits will be lost:{" "} + {view.drift_keys.slice(0, 6).join(", ")} + {view.drift_keys.length > 6 ? "…" : ""} +

+
+ )} + +
+ Presets: + {presets.map((p) => ( + + ))} + +
+ + {preview && ( + +

{preview.title}

+

{preview.description}

+ {preview.changes.length === 0 ? ( +

Nothing to change — your settings already match.

+ ) : ( + <> +

+ This will change {preview.changes.length} setting{preview.changes.length === 1 ? "" : "s"}: +

+
    + {preview.changes.map((c) => { + const s = view.settings.find((x) => x.key === c.key)!; + return ( +
  • + {c.title} + + {fmt(c.from, s)} → {fmt(c.to, s)} + +
  • + ); + })} +
+ + )} +
+ {preview.changes.length > 0 && ( + + )} + +
+
+ )} + + {saved && ( + + {saved.changed.length === 0 ? ( +

Nothing changed.

+ ) : ( + <> +

Saved.

+

+ {running && saved.restart + ? `Restart required to apply ${saved.changed.length} setting${saved.changed.length === 1 ? "" : "s"}.` + : "The changes will apply the next time the server starts."} +

+ {running && saved.restart && ( +
+ + +
+ )} + + )} +
+ )} + + {saveErr && ( + +

{saveErr.human.title}

+

Nothing was saved. Fix the highlighted settings and try again.

+
+ )} + +
+ {view.categories + .filter((c) => basicCount(c.id) + advancedCount(c.id) > 0) + .map((c) => ( + + ))} +
+ +
+ {basic.map((s) => ( + setDraft((d) => ({ ...d, [s.key]: v }))} + onReset={() => setDraft((d) => ({ ...d, [s.key]: s.default }))} + /> + ))} +
+ + {advanced.length > 0 && ( +
+ + {showAdvanced && ( +
+ {advanced.map((s) => ( + setDraft((d) => ({ ...d, [s.key]: v }))} + onReset={() => setDraft((d) => ({ ...d, [s.key]: s.default }))} + /> + ))} +
+ )} +
+ )} + + {view.unknown_keys > 0 && ( +

{view.unknown_keys} other setting(s) in the configuration file are kept exactly as they are.

+ )} + + {dirtyKeys.length > 0 && ( +
+ {dirtyKeys.length} unsaved change{dirtyKeys.length === 1 ? "" : "s"} +
+ + +
+
+ )} +
+ ); +} diff --git a/src/screens/Shell.tsx b/src/screens/Shell.tsx index 37e3424..e676443 100644 --- a/src/screens/Shell.tsx +++ b/src/screens/Shell.tsx @@ -4,6 +4,7 @@ import { type ServerSummary } from "@/lib/api"; import { cn } from "@/lib/utils"; import { Overview } from "@/screens/Overview"; import { Placeholder } from "@/screens/Placeholder"; +import { SettingsPage } from "@/screens/SettingsPage"; import { Button } from "@/components/ui/button"; type Page = "overview" | "bots" | "server" | "players" | "friends" | "backups" | "console" | "settings"; @@ -76,6 +77,8 @@ export function Shell(props: {
{page === "overview" ? ( props.onForget(server.id)} /> + ) : page === "bots" || page === "server" ? ( + ) : ( )} diff --git a/tests/e2e/cdp.mjs b/tests/e2e/cdp.mjs index f26f11d..648f163 100644 --- a/tests/e2e/cdp.mjs +++ b/tests/e2e/cdp.mjs @@ -17,8 +17,21 @@ export function evaluate(expression) { ws.send(JSON.stringify({ id: n, method: "Runtime.evaluate", params: { expression, awaitPromise: true, returnByValue: true } })); }); } -if (process.argv[2]) { +if (process.argv[2] && process.argv[2] !== "-") { const r = await evaluate(process.argv[2]); console.log(JSON.stringify(r.result?.result?.value ?? r.result, null, 2)); ws.close(); } + +export async function screenshot(file) { + const r = await new Promise((resolve) => { + const n = ++id; + pending.set(n, resolve); + ws.send(JSON.stringify({ id: n, method: "Page.captureScreenshot", params: { format: "png" } })); + }); + (await import("node:fs")).writeFileSync(file, Buffer.from(r.result.data, "base64")); +} +if (process.argv[3]) { + await screenshot(process.argv[3]); + ws.close(); +} diff --git a/tools/gen_bots_schema.py b/tools/gen_bots_schema.py new file mode 100644 index 0000000..bd9c427 --- /dev/null +++ b/tools/gen_bots_schema.py @@ -0,0 +1,117 @@ +"""Regenerates schemas/bots.json and schemas/presets/bots.json. Defaults mirror module/conf/mod_coa_playerbots.conf.dist.""" +import json +import os + +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +cats = [("general", "General"), ("population", "Population"), ("login", "Login"), ("roles", "Roles"), + ("openworld", "Open World"), ("quests", "Quests"), ("combat", "Combat"), ("dungeon", "Dungeon Finder"), + ("battlegrounds", "Battlegrounds"), ("social", "Social Behavior"), ("advanced", "Advanced")] +P = "CoaBots." +# key, type, category, title, description, default, min, max, unit, advanced, dangerous +R = [ + ("RandomSpawn.DefaultCount", "int", "population", "Bots created per spawn command", "How many bots the spawn command creates when you do not give a number.", 10, 1, 1000, None, False, False), + ("RandomSpawn.MaxCount", "int", "population", "Largest single spawn", "Safety limit for one spawn command. Very large batches can freeze the server for a moment, so raise this only when nobody else is playing.", 1000, 1, 5000, None, True, True), + ("LeveledSpawn.Brackets", "string", "population", "Level mix of new bots", "How new levelling bots are spread over levels, as ranges with weights (1-20:40 means 40 parts of bots between level 1 and 20).", "1-20:40,21-60:33,61-79:20,80-80:7", None, None, None, True, False), + ("RandomSpawn.AutoLogin", "bool", "population", "Bring new bots online immediately", "Newly created bots log in right away instead of waiting in the database.", True, None, None, None, False, False), + ("RandomSpawn.AccountPrefix", "string", "population", "Bot account name prefix", "Bots live on accounts that start with this name. Changing it makes existing bots stop logging in automatically.", "CoaBotHost", None, None, None, True, True), + ("RandomSpawn.BatchSize", "int", "population", "Bots created per step", "Creation is spread over time to keep the server smooth. Higher values are faster but can cause freezes.", 5, 1, 50, None, True, True), + ("RandomSpawn.BatchIntervalMs", "int", "population", "Pause between creation steps", "Time between creation steps. Lower values are faster but can cause freezes.", 500, 100, 60000, "ms", True, True), + ("AutoLoginOnStartup", "bool", "login", "Automatic bot login", "Existing bots log themselves in every time the server starts.", False, None, None, None, False, False), + ("AutoLogin.MaxCount", "int", "login", "Maximum bots online", "The most bots that log in automatically at startup.", 800, 0, 5000, None, False, False), + ("AutoLogin.BatchSize", "int", "login", "Bots logging in per step", "Logins are spread over time. Higher values bring bots online faster but load the server harder.", 10, 1, 100, None, True, True), + ("AutoLogin.BatchIntervalMs", "int", "login", "Pause between login steps", "Time between login steps.", 500, 100, 60000, "ms", True, True), + ("SpecBalance.TankTargetPct", "int", "roles", "Tank population target", "Share of bots that should be tanks.", 20, 0, 50, "%", False, False), + ("SpecBalance.HealerTargetPct", "int", "roles", "Healer population target", "Share of bots that should be healers.", 20, 0, 50, "%", False, False), + ("TalentBuildsPath", "string", "general", "Talent data file", "Where the community talent builds are stored. Leave empty to use the built-in search.", "", None, None, None, True, False), + ("World.Enable", "bool", "openworld", "Bots wander the world", "Idle bots run errands: visit town services, travel to gathering or grinding areas, or wander a little.", True, None, None, None, False, False), + ("WorldBrain.Enable", "bool", "quests", "Questing AI", "Bots pick up and complete quests on their own. When off, bots only gather, fish, grind and wander.", True, None, None, None, False, False), + ("World.ServiceRadius", "int", "openworld", "Town services search radius", "How far a bot looks for vendors and mailboxes.", 200, 50, 1000, "yards", True, False), + ("World.AreaRadius", "int", "openworld", "Errand area radius", "How far a bot looks for gathering or grinding areas.", 350, 50, 2000, "yards", True, False), + ("World.ThinkIntervalMs", "int", "advanced", "Errand check interval", "How often each bot reconsiders its errand.", 2000, 500, 60000, "ms", True, False), + ("World.IdleBeforeErrandMs", "int", "advanced", "Idle time before an errand", "How long a bot must find nothing to do before it starts an errand.", 10000, 1000, 600000, "ms", True, False), + ("World.VerboseLog", "bool", "advanced", "Detailed errand logging", "Writes every errand step to the log. Useful for debugging, noisy with many bots.", True, None, None, None, True, False), + ("Combat.VerboseLog", "bool", "advanced", "Detailed combat logging", "Writes combat decisions to the log. Very noisy; turn on only while hunting a specific problem.", False, None, None, None, True, False), + ("Profile.IntentMinMs", "int", "advanced", "Shortest activity", "The shortest time a bot sticks with one kind of activity.", 900000, 60000, 7200000, "ms", True, False), + ("Profile.IntentMaxMs", "int", "advanced", "Longest activity", "The longest time a bot sticks with one kind of activity.", 2100000, 60000, 14400000, "ms", True, False), + ("Grind.SearchRadius", "int", "openworld", "Grinding search radius", "How far a bot looks for monsters to grind.", 30, 5, 200, "yards", False, False), + ("Grind.MaxLevelAbove", "int", "openworld", "Highest monster level to grind", "How many levels above itself a bot will fight while grinding.", 3, 0, 10, "levels", False, False), + ("Healer.CriticalHealPct", "int", "combat", "Emergency heal threshold", "Healers switch to their strongest heals when an ally falls below this health.", 50, 5, 95, "%", False, False), + ("WorldBrain.SearchRadius", "int", "openworld", "Target search radius", "How far a bot scans for quest targets inside an objective area.", 50, 10, 300, "yards", True, False), + ("WorldBrain.EngageRange", "int", "combat", "Engage distance", "Distance at which a bot starts fighting a quest monster.", 28, 5, 40, "yards", True, False), + ("WorldBrain.MountDistanceMin", "int", "openworld", "Mount up beyond (min)", "Each bot mounts for trips longer than a personal distance drawn between this and the maximum.", 60, 10, 500, "yards", True, False), + ("WorldBrain.MountDistanceMax", "int", "openworld", "Mount up beyond (max)", "Upper end of the personal mount distance.", 95, 10, 500, "yards", True, False), + ("WorldBrain.TaxiMinDistance", "int", "openworld", "Use flight paths beyond", "Trips longer than this use a flight path when the bot knows a route.", 900, 100, 10000, "yards", True, False), + ("WorldBrain.SessionBreaks", "bool", "openworld", "Take breaks", "Bots alternate questing with a few minutes of town life, like a player would.", True, None, None, None, False, False), + ("WorldBrain.GatherDetours", "bool", "openworld", "Gather nearby herbs and ore", "Gatherers step off their path for a node right next to it.", True, None, None, None, False, False), + ("WorldBrain.MaxActiveQuests", "int", "quests", "Maximum active quests", "How many quests a bot carries at once.", 15, 1, 25, None, False, False), + ("WorldBrain.AcceptEliteQuests", "bool", "quests", "Accept elite quests", "Solo bots take on elite quests. Off by default because they usually cannot finish them alone.", False, None, None, None, False, False), + ("WorldBrain.QuestLevelBelow", "int", "quests", "Quests below own level", "How many levels below itself a bot still accepts quests.", 6, 0, 20, "levels", False, False), + ("WorldBrain.QuestLevelAbove", "int", "quests", "Quests above own level", "How many levels above itself a bot still accepts quests.", 3, 0, 10, "levels", False, False), + ("WorldBrain.MaxMobLevelAbove", "int", "quests", "Highest quest monster level", "How far above its level a quest monster may be.", 3, 0, 10, "levels", False, False), + ("WorldBrain.MaxObjectiveDistance", "int", "quests", "Farthest quest objective", "Quests whose objectives are farther than this are not taken.", 3000, 200, 10000, "yards", True, False), + ("WorldBrain.GiverSearchRadius", "int", "quests", "Quest giver search radius", "How far a bot looks for quest givers.", 500, 50, 3000, "yards", True, False), + ("WorldBrain.MaxBadAreasPerObjective", "int", "quests", "Areas tried per objective", "How many areas a bot gives up on before setting the quest aside.", 3, 1, 10, None, True, False), + ("WorldBrain.QuestSuspendMs", "int", "quests", "Set-aside time (short)", "How long a quest is set aside after the bot gives up on it.", 600000, 60000, 86400000, "ms", True, False), + ("WorldBrain.QuestSuspendMaxMs", "int", "quests", "Set-aside time (long)", "The longest a quest can stay set aside after repeated failures.", 7200000, 60000, 86400000, "ms", True, False), + ("LfgFill.Enable", "bool", "dungeon", "Dungeon Finder bot fill", "When you queue alone for a dungeon, bots fill the missing tank, healer and damage roles.", True, None, None, None, False, False), + ("AutoQueueDungeon.Enable", "bool", "dungeon", "Bots queue for dungeons by themselves", "Experimental: idle tank bots queue for a dungeon on their own and the fill above completes the party.", False, None, None, None, True, True), + ("BGFill.Enable", "bool", "battlegrounds", "Battleground bot fill", "When anyone queues for a battleground, both teams are topped up with bots so the match starts at once.", True, None, None, None, False, False), + ("BGFill.TargetPlayersPerTeam", "int", "battlegrounds", "Battleground team size", "Bots per team to fill up to. 0 means a full team for that battleground.", 0, 0, 40, None, False, False), + ("WorldBrain.HelpOthers", "bool", "social", "Bots help nearby players", "A passing bot joins a fight that a friendly player is losing, without taking their loot or credit.", True, None, None, None, False, False), + ("WorldBrain.HelpRadius", "int", "social", "Help distance", "How close a bot must be to step into a fight.", 30, 5, 100, "yards", True, False), + ("WorldBrain.HelpHealthPct", "int", "social", "Help below health", "Bots help when the fighting player is below this health.", 40, 5, 95, "%", True, False), + ("WorldBrain.ResurrectOthers", "bool", "social", "Bots resurrect fallen players", "Bots that know a resurrection spell revive friendly corpses.", True, None, None, None, False, False), + ("WorldBrain.TemporaryParties", "bool", "social", "Temporary bot parties", "Bots of similar level with the same quest briefly team up.", False, None, None, None, False, False), + ("WorldBrain.PartyMaxSize", "int", "social", "Temporary party size", "Largest temporary party.", 3, 2, 5, None, True, False), + ("WorldBrain.PartyRadius", "int", "social", "Party formation distance", "How close bots must be to team up.", 60, 10, 300, "yards", True, False), + ("WorldBrain.PartyMinMinutes", "int", "social", "Shortest party time", "Minimum duration of a temporary party.", 5, 1, 120, "min", True, False), + ("WorldBrain.PartyMaxMinutes", "int", "social", "Longest party time", "Maximum duration of a temporary party.", 20, 1, 240, "min", True, False), + ("WorldBrain.PlannerMinMs", "int", "advanced", "Planner interval (min)", "Shortest time between a bot's planning passes.", 2000, 500, 60000, "ms", True, False), + ("WorldBrain.PlannerMaxMs", "int", "advanced", "Planner interval (max)", "Longest time between a bot's planning passes.", 8000, 500, 120000, "ms", True, False), + ("WorldBrain.ScanMinMs", "int", "advanced", "Target scan interval (min)", "Shortest time between target scans.", 500, 100, 10000, "ms", True, False), + ("WorldBrain.ScanMaxMs", "int", "advanced", "Target scan interval (max)", "Longest time between target scans.", 1500, 100, 20000, "ms", True, False), + ("WorldBrain.SearchTimeoutMs", "int", "advanced", "Area search time limit", "How long a bot searches an empty area before trying another.", 35000, 5000, 600000, "ms", True, False), + ("WorldBrain.ApproachTimeoutMs", "int", "advanced", "Approach time limit", "How long a bot walks toward a claimed target.", 25000, 5000, 600000, "ms", True, False), + ("WorldBrain.TravelTimeoutMs", "int", "advanced", "Travel time limit", "How long a whole trip to an area may take.", 360000, 30000, 3600000, "ms", True, False), +] +for n, d in [("TravelPer100Yards", 7), ("CrowdPerBot", 6), ("Overlap", 35), ("TurnIn", 120), ("Accept", 55), ("AreaOccupancy", 12), ("TargetReserved", 200)]: + R.append(("WorldBrain.Weight." + n, "int", "advanced", "Preference weight: " + n, "How strongly bots weigh this factor when choosing what to do next. Change only if you know the planner.", d, 0, 1000, None, True, False)) + +settings = [] +for k, t, c, title, desc, d, mn, mx, unit, adv, dang in R: + s = {"key": P + k, "type": t, "category": c, "title": title, "description": desc, "default": d, + "advanced": adv, "restartRequired": "world", "dangerous": dang} + if mn is not None: + s["min"] = mn + if mx is not None: + s["max"] = mx + if unit: + s["unit"] = unit + settings.append(s) + +with open(os.path.join(ROOT, "schemas", "bots.json"), "w", encoding="utf-8", newline="\n") as f: + json.dump({"schema": 1, "scope": "bots", "categories": [{"id": i, "title": t} for i, t in cats], "settings": settings}, f, indent=2, ensure_ascii=False) + f.write("\n") + +quiet = {"CoaBots.World.VerboseLog": False, "CoaBots.Combat.VerboseLog": False} +presets = [ + {"id": "balanced", "title": "Balanced", "description": "The recommended everyday setup: questing, dungeon and battleground fill on, quiet logs.", + "values": {**quiet, "CoaBots.AutoLoginOnStartup": True, "CoaBots.AutoLogin.MaxCount": 300, "CoaBots.WorldBrain.Enable": True, + "CoaBots.LfgFill.Enable": True, "CoaBots.BGFill.Enable": True, "CoaBots.WorldBrain.TemporaryParties": False}}, + {"id": "solo-friendly", "title": "Solo Friendly", "description": "Bots that help you and fill your groups, with a small quiet population.", + "values": {**quiet, "CoaBots.AutoLoginOnStartup": True, "CoaBots.AutoLogin.MaxCount": 100, "CoaBots.WorldBrain.HelpOthers": True, + "CoaBots.WorldBrain.ResurrectOthers": True, "CoaBots.LfgFill.Enable": True, "CoaBots.BGFill.Enable": True, + "CoaBots.WorldBrain.TemporaryParties": False}}, + {"id": "living-world", "title": "Living World", "description": "A busier world: more bots, breaks, gathering detours and temporary parties.", + "values": {**quiet, "CoaBots.AutoLoginOnStartup": True, "CoaBots.AutoLogin.MaxCount": 500, "CoaBots.World.Enable": True, + "CoaBots.WorldBrain.Enable": True, "CoaBots.WorldBrain.SessionBreaks": True, "CoaBots.WorldBrain.GatherDetours": True, + "CoaBots.WorldBrain.TemporaryParties": True}}, + {"id": "performance", "title": "Performance", "description": "Fewer bots and less bot thinking, for weaker computers.", + "values": {**quiet, "CoaBots.AutoLoginOnStartup": True, "CoaBots.AutoLogin.MaxCount": 50, "CoaBots.WorldBrain.TemporaryParties": False, + "CoaBots.WorldBrain.GatherDetours": False, "CoaBots.WorldBrain.SessionBreaks": False, "CoaBots.WorldBrain.MaxActiveQuests": 8}}, +] +os.makedirs(os.path.join(ROOT, "schemas", "presets"), exist_ok=True) +with open(os.path.join(ROOT, "schemas", "presets", "bots.json"), "w", encoding="utf-8", newline="\n") as f: + json.dump({"schema": 1, "presets": presets}, f, indent=2) + f.write("\n") +print(len(settings), "bot settings") diff --git a/tools/gen_server_schema.py b/tools/gen_server_schema.py new file mode 100644 index 0000000..f22a31b --- /dev/null +++ b/tools/gen_server_schema.py @@ -0,0 +1,83 @@ +"""Regenerates schemas/server.json and schemas/presets/server.json (curated worldserver.conf whitelist). +Every key was verified to exist in the repack's Settings/worldserver.conf.template; defaults are the shipped values.""" +import json +import os + +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +cats = [("gameplay", "Gameplay"), ("rates", "Rates"), ("characters", "Characters"), ("world", "World"), ("network", "Network"), + ("accounts", "Accounts"), ("logging", "Logging"), ("performance", "Performance"), ("advanced", "Advanced")] +# key, type, category, title, description, default, min, max, unit, advanced, restart, dangerous +R = [ + ("Rate.XP.Kill", "float", "rates", "Experience from kills", "Multiplier for experience gained by killing monsters. 1 is normal.", 1, 0, 20, "x", False, "world", False), + ("Rate.XP.Quest", "float", "rates", "Experience from quests", "Multiplier for experience gained from quests.", 1, 0, 20, "x", False, "world", False), + ("Rate.XP.Quest.DF", "float", "rates", "Experience from daily / dungeon-finder quests", "Multiplier for experience from Dungeon Finder quests.", 1, 0, 20, "x", True, "world", False), + ("Rate.XP.Explore", "float", "rates", "Experience from exploring", "Multiplier for experience from discovering new areas.", 1, 0, 20, "x", False, "world", False), + ("Rate.Drop.Item.Normal", "float", "rates", "Item drop rate", "Multiplier for how often normal-quality items drop.", 1, 0, 20, "x", False, "world", False), + ("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.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), + ("GameType", "enum", "advanced", "Realm type", "The realm type shown in the realm list.", 0, None, None, None, True, "world", False), + ("RealmZone", "int", "advanced", "Realm zone", "The realm's language / region group shown to clients.", 1, 0, 30, None, True, "world", False), + ("PlayerSaveInterval", "int", "world", "Autosave interval", "How often player characters are saved while online.", 900000, 60000, 3600000, "ms", False, "world", False), + ("CharactersPerAccount", "int", "characters", "Characters per account", "Maximum characters an account can have across all realms.", 50, 1, 200, None, False, "world", False), + ("CharactersPerRealm", "int", "characters", "Characters per realm", "Maximum characters an account can have on this realm.", 10, 1, 50, None, False, "world", False), + ("StartPlayerLevel", "int", "characters", "Starting level", "Level new characters start at.", 1, 1, 80, None, False, "world", False), + ("StartPlayerMoney", "int", "characters", "Starting gold", "Copper new characters start with (10000 copper = 1 gold).", 0, 0, 2147483647, "copper", False, "world", False), + ("MaxPlayerLevel", "int", "characters", "Maximum level", "Highest level a character can reach. The game client only supports 80.", 80, 1, 80, None, True, "world", True), + ("SkipCinematics", "enum", "characters", "Skip intro cinematics", "Whether new characters see their intro movie.", 0, None, None, None, False, "world", False), + ("DurabilityLoss.OnDeath", "float", "gameplay", "Durability lost on death", "Percent of item durability lost when you die.", 10, 0, 100, "%", False, "world", False), + ("Death.SicknessLevel", "int", "gameplay", "Resurrection sickness up to level", "Characters above this level get resurrection sickness. -10 turns it off.", 11, -10, 80, None, False, "world", False), + ("LeaveGroupOnLogout.Enabled", "bool", "gameplay", "Leave group on logout", "Players are removed from their group when they log out.", False, None, None, None, True, "world", False), + ("GM.LoginState", "enum", "gameplay", "GM mode at login", "Whether game masters start with GM mode on.", 2, None, None, None, True, "world", False), + ("AllowTwoSide.Accounts", "bool", "accounts", "Both factions on one account", "One account may have Alliance and Horde characters.", True, None, None, None, False, "world", False), + ("AllowTwoSide.Interaction.Chat", "bool", "accounts", "Cross-faction chat", "Alliance and Horde players can talk to each other.", False, None, None, None, False, "world", False), + ("AutoBroadcast.On", "bool", "world", "Automatic announcements", "Broadcast the messages stored in the database at regular intervals.", False, None, None, None, True, "world", False), + ("AutoBroadcast.Timer", "int", "world", "Announcement interval", "Time between automatic announcements.", 60000, 5000, 3600000, "ms", True, "world", False), + ("Warden.Enabled", "bool", "advanced", "Anti-cheat (Warden)", "Enables the client anti-cheat check. Usually unnecessary on a private server.", True, None, None, None, True, "world", False), + ("MapUpdateInterval", "int", "performance", "Map update interval", "How often the world updates maps. Lower is smoother but uses more CPU.", 10, 1, 1000, "ms", True, "world", False), + ("MinWorldUpdateTime", "int", "performance", "Minimum world update time", "Shortest time between world updates. Raise it to reduce CPU use.", 1, 1, 1000, "ms", True, "world", False), + ("ThreadPool", "int", "performance", "Worker threads", "Number of background worker threads. More helps large populations.", 2, 1, 32, None, True, "full", False), + ("Network.Threads", "int", "network", "Network threads", "Threads that handle player connections. One is enough for small servers.", 1, 1, 16, None, True, "full", False), + ("Ra.Enable", "bool", "advanced", "Remote console", "Lets the Manager and scripts send commands to the server. It only listens on this computer; keep it that way.", True, None, None, None, True, "full", True), + ("Ra.IP", "string", "advanced", "Remote console address", "Address the remote console listens on. Keep 127.0.0.1 so it is not reachable from the internet.", "127.0.0.1", None, None, None, True, "full", True), + ("Updates.EnableDatabases", "int", "advanced", "Automatic database updates", "Lets the server change its own databases at start. Keep 0: the Manager applies and tracks database updates safely.", 0, 0, 7, None, True, "full", True), +] +enums = { + "GameType": [(0, "Normal"), (1, "Player vs Player"), (6, "Roleplay"), (8, "Roleplay + PvP")], + "SkipCinematics": [(0, "Show all"), (1, "Skip for non-first characters"), (2, "Skip all")], + "GM.LoginState": [(0, "Off"), (1, "On"), (2, "Same as last time")], +} +settings = [] +for k, t, c, title, desc, d, mn, mx, unit, adv, rs, dang in R: + s = {"key": k, "type": t, "category": c, "title": title, "description": desc, "default": d, + "advanced": adv, "restartRequired": rs, "dangerous": dang} + if mn is not None: + s["min"] = mn + if mx is not None: + s["max"] = mx + if unit: + s["unit"] = unit + if k in enums: + s["options"] = [{"value": v, "label": l} for v, l in enums[k]] + settings.append(s) + +with open(os.path.join(ROOT, "schemas", "server.json"), "w", encoding="utf-8", newline="\n") as f: + json.dump({"schema": 1, "scope": "server", "categories": [{"id": i, "title": t} for i, t in cats], "settings": settings}, f, indent=2, ensure_ascii=False) + f.write("\n") + +presets = [ + {"id": "vanilla", "title": "Standard rates", "description": "Everything at normal (x1) speed.", + "values": {"Rate.XP.Kill": 1, "Rate.XP.Quest": 1, "Rate.XP.Explore": 1, "Rate.Drop.Item.Normal": 1, "Rate.Drop.Money": 1, "Rate.Honor": 1, "Rate.Reputation.Gain": 1}}, + {"id": "relaxed", "title": "Relaxed", "description": "Faster levelling and more drops for casual play (x2 / x3).", + "values": {"Rate.XP.Kill": 2, "Rate.XP.Quest": 2, "Rate.XP.Explore": 2, "Rate.Drop.Item.Normal": 2, "Rate.Drop.Money": 3, "Rate.Honor": 2, "Rate.Reputation.Gain": 2}}, + {"id": "fast", "title": "Fast", "description": "Quick progression for testing or short sessions (x5).", + "values": {"Rate.XP.Kill": 5, "Rate.XP.Quest": 5, "Rate.XP.Explore": 5, "Rate.Drop.Item.Normal": 3, "Rate.Drop.Money": 5, "Rate.Honor": 3, "Rate.Reputation.Gain": 3}}, +] +with open(os.path.join(ROOT, "schemas", "presets", "server.json"), "w", encoding="utf-8", newline="\n") as f: + json.dump({"schema": 1, "presets": presets}, f, indent=2) + f.write("\n") +print(len(settings), "server settings")