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 <noreply@anthropic.com>
This commit is contained in:
ДмитрийandClaude Sonnet 5.5 committed 2026-09-30 07:33:54 +05:00
1 parent 19024a3162
commit 5b5255cca8
18 files changed
+3668 -3

No files matched your search

+49
View File
@@ -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 <server folder>");
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}"),
}
}
}
+69
View File
@@ -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<String>,
/// Present in the user's file but unknown to the `.dist` (possibly removed upstream, possibly a custom setting).
pub not_in_dist: Vec<String>,
}
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::<Vec<_>>();
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<String>) -> Vec<String> {
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"));
}
}
+582
View File
@@ -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<Schema> = OnceLock::new();
static SERVER: OnceLock<Schema> = 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<PresetFile> = OnceLock::new();
static SERVER: OnceLock<PresetFile> = 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<PathBuf>,
/// The generated file the launcher rewrites from a template at each start, when applicable.
pub generated: Option<PathBuf>,
}
pub fn targets(root: &Path, scope: Scope) -> Result<Targets> {
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> {
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<String>,
/// 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<Category>,
pub settings: Vec<SettingView>,
/// Keys in the file the schema does not know (kept untouched).
pub unknown_keys: usize,
pub drift_keys: Vec<String>,
pub files: Vec<String>,
}
/// 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<Vec<String>> {
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<SettingsView> {
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<Value>,
pub to: Value,
pub restart: Restart,
pub dangerous: bool,
}
#[derive(Debug, Serialize)]
pub struct SaveReport {
pub changed: Vec<Change>,
/// Strongest restart any changed setting needs, if any changed.
pub restart: Option<Restart>,
pub snapshot: Option<String>,
}
fn effective(view: &SettingsView, changes: &BTreeMap<String, Value>, key: &str) -> Option<f64> {
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<String, Value>) -> Result<BTreeMap<String, String>> {
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<SnapshotFile>,
}
fn take_snapshot(meta_dir: &Path, scope: Scope, reason: &str, files: &[(PathBuf, Vec<u8>)]) -> Result<String> {
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<SnapshotInfo> {
let mut out: Vec<SnapshotInfo> = 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<String, Value>) -> Result<SaveReport> {
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<u8>)> = Vec::new();
let mut confs: Vec<ConfFile> = 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<String> = 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", &current)?;
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<PresetChange>,
}
fn diff(view: &SettingsView, wanted: impl Iterator<Item = (String, Value)>) -> Vec<PresetChange> {
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<PresetPreview> {
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<PresetPreview> {
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<String, Value> {
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());
}
}
+219
View File
@@ -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<Line>,
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<ConfFile> {
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<Item = (&str, &str)> {
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<String> {
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());
}
}
+277
View File
@@ -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<f64>,
#[serde(default)]
pub max: Option<f64>,
#[serde(default)]
pub options: Vec<EnumOption>,
#[serde(default)]
pub unit: Option<String>,
#[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<Category>,
pub settings: Vec<Setting>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Preset {
pub id: String,
pub title: String,
pub description: String,
pub values: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PresetFile {
pub schema: u32,
pub presets: Vec<Preset>,
}
impl Schema {
pub fn parse(json: &str) -> Result<Schema> {
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<String, String> {
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<Value, String> {
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::<i64>().map(Value::from).map_err(|_| format!("{t:?} is not a whole number")),
SettingType::Float => unq
.parse::<f64>()
.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<f64>, max: Option<f64>) -> 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());
}
}
+16 -1
View File
@@ -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::<Vec<_>>().join("; "))]
Validation(Vec<FieldError>),
}
/// 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<FieldError>,
}
impl From<Error> 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 }
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod config;
pub mod driver;
pub mod error;
pub mod fsx;
+976
View File
@@ -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
}
]
}
+67
View File
@@ -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
}
}
]
}
+47
View File
@@ -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
}
}
]
}
+568
View File
@@ -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
}
]
}
+82 -1
View File
@@ -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<T: Send + 'static>(f: impl FnOnce() -> Result<T> + Send + 'static) -> std::result::Result<T, UiError> {
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<PathBuf> {
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<SettingsView, UiError> {
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<String, Value>,
) -> std::result::Result<config::SaveReport, UiError> {
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<PresetInfo> {
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<config::PresetPreview, UiError> {
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<Vec<config::SnapshotInfo>, 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");
+74
View File
@@ -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<StatusView>("server_status", { id }),
start: (id: string) => invoke<DriverOutcome>("start_server", { id }),
stop: (id: string) => invoke<DriverOutcome>("stop_server", { id }),
settings: (id: string, scope: Scope) => invoke<SettingsView>("get_settings", { id, scope }),
save: (id: string, scope: Scope, changes: Record<string, JsonValue>) => invoke<SaveReport>("save_settings", { id, scope, changes }),
presets: (scope: Scope) => invoke<PresetInfo[]>("list_presets", { scope }),
previewPreset: (id: string, scope: Scope, preset: string) => invoke<PresetPreview>("preview_preset", { id, scope, preset }),
};
export function asUiError(e: unknown): UiError {
+424
View File
@@ -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 (
<button
id={id}
role="switch"
aria-checked={value === true}
onClick={() => onChange(!(value === true))}
className={cn("relative h-7 w-14 cursor-pointer rounded-full transition-colors", value === true ? "bg-gold" : "bg-white/15")}
>
<span className={cn("absolute left-1 top-1 h-5 w-5 rounded-full bg-white transition-transform", value === true && "translate-x-7")} />
<span className="sr-only">{value === true ? "On" : "Off"}</span>
</button>
);
}
if (s.type === "enum") {
return (
<select
id={id}
className={cn(base, "border-line")}
value={String(value)}
onChange={(e) => onChange(s.options.find((o) => String(o.value) === e.target.value)!.value)}
>
{s.options.map((o) => (
<option key={String(o.value)} value={String(o.value)}>
{o.label}
</option>
))}
</select>
);
}
const numeric = s.type === "int" || s.type === "float";
return (
<div className="flex items-center gap-2">
<input
id={id}
className={cn(base, numeric ? "w-32 text-right" : "w-72", error ? "border-bad" : "border-line")}
value={String(value)}
inputMode={numeric ? "decimal" : undefined}
aria-invalid={!!error}
onChange={(e) => {
const t = e.target.value;
onChange(numeric ? (t.trim() === "" || Number.isNaN(Number(t)) ? (t as unknown as number) : Number(t)) : t);
}}
/>
{s.unit && <span className="text-xs text-muted">{s.unit}</span>}
</div>
);
}
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 (
<div className="grid grid-cols-[1fr_auto] items-start gap-x-6 gap-y-1 py-3.5">
<div>
<label htmlFor={`f-${s.key}`} className="flex flex-wrap items-center gap-2 font-medium">
{s.title}
{s.dangerous && (
<span className="inline-flex items-center gap-1 rounded bg-warn/15 px-1.5 py-0.5 text-[11px] font-normal text-warn">
<AlertTriangle className="h-3 w-3" aria-hidden /> Careful
</span>
)}
{changed && <span className="rounded bg-gold/15 px-1.5 py-0.5 text-[11px] font-normal text-gold">Changed</span>}
</label>
<p className="mt-0.5 max-w-xl text-sm text-muted">{s.description}</p>
<p className="mt-1 text-xs text-muted/80">
Default: {fmt(s.default, s)}
{s.min !== undefined && s.max !== undefined && s.type !== "bool" ? ` · Range ${s.min}–${s.max}` : ""} · {RESTART_LABEL[s.restartRequired]}
</p>
{s.problem && <p className="mt-1 text-xs text-warn">The file has an unusable value ({s.problem}); showing the default.</p>}
{s.drift && <p className="mt-1 text-xs text-warn">Edited by hand in the generated file — the launcher will reset it at next start unless you save it here.</p>}
{error && (
<p role="alert" className="mt-1 text-xs text-bad">
{error}
</p>
)}
</div>
<div className="flex items-center gap-2 pt-0.5">
<Field s={s} value={value} error={error} onChange={props.onChange} />
<button
title="Reset to default"
aria-label={`Reset ${s.title} to default`}
onClick={props.onReset}
disabled={value === s.default}
className="cursor-pointer rounded p-1.5 text-muted hover:bg-white/5 hover:text-ink disabled:opacity-25"
>
<RotateCcw className="h-4 w-4" aria-hidden />
</button>
</div>
</div>
);
}
export function SettingsPage(props: { serverId: string; scope: Scope; title: string; question: string }) {
const { serverId, scope } = props;
const [view, setView] = useState<SettingsView | null>(null);
const [draft, setDraft] = useState<Record<string, JsonValue>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [fatal, setFatal] = useState<UiError | null>(null);
const [saveErr, setSaveErr] = useState<UiError | null>(null);
const [saved, setSaved] = useState<SaveReport | null>(null);
const [presets, setPresets] = useState<PresetInfo[]>([]);
const [preview, setPreview] = useState<PresetPreview | null>(null);
const [category, setCategory] = useState<string>("");
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 (
<div className="max-w-xl">
<h1 className="text-2xl font-semibold">{props.title}</h1>
<Card className="mt-6 p-5">
<p className="font-medium">{fatal.human.code === "unknown" ? "Nothing to configure yet" : fatal.human.title}</p>
<p className="mt-1 text-sm text-muted">{fatal.human.code === "unknown" ? fatal.technical : fatal.human.message}</p>
</Card>
</div>
);
}
if (!view) return <p className="text-muted">Loading settings…</p>;
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<string, JsonValue>) {
const local: Record<string, string> = {};
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<string, string> = {};
(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 (
<div className="max-w-3xl pb-24">
<h1 className="text-2xl font-semibold">{props.title}</h1>
<p className="mt-1 text-muted">{props.question}</p>
{view.drift_keys.length > 0 && (
<Card className="mt-5 border-warn/40 p-4" role="status">
<p className="font-medium text-warn">{view.drift_keys.length} setting(s) were edited by hand</p>
<p className="mt-1 text-sm text-muted">
The server launcher rebuilds worldserver.conf from its template every time it starts, so these manual edits will be lost:{" "}
<span className="selectable">{view.drift_keys.slice(0, 6).join(", ")}</span>
{view.drift_keys.length > 6 ? "…" : ""}
</p>
</Card>
)}
<div className="mt-5 flex flex-wrap items-center gap-2">
<span className="text-sm text-muted">Presets:</span>
{presets.map((p) => (
<Button key={p.id} size="sm" title={p.description} onClick={() => void openPreset(p.id)}>
{p.title}
</Button>
))}
<Button size="sm" variant="ghost" onClick={() => void openPreset("defaults")}>
Restore recommended defaults
</Button>
</div>
{preview && (
<Card className="mt-4 border-gold/40 p-5">
<p className="font-medium">{preview.title}</p>
<p className="text-sm text-muted">{preview.description}</p>
{preview.changes.length === 0 ? (
<p className="mt-3 text-sm">Nothing to change — your settings already match.</p>
) : (
<>
<p className="mt-3 text-sm">
This will change <b>{preview.changes.length}</b> setting{preview.changes.length === 1 ? "" : "s"}:
</p>
<ul className="mt-2 max-h-56 divide-y divide-line overflow-auto text-sm">
{preview.changes.map((c) => {
const s = view.settings.find((x) => x.key === c.key)!;
return (
<li key={c.key} className="flex justify-between gap-4 py-1.5">
<span>{c.title}</span>
<span className="text-muted">
{fmt(c.from, s)} → <span className="text-ink">{fmt(c.to, s)}</span>
</span>
</li>
);
})}
</ul>
</>
)}
<div className="mt-4 flex gap-2">
{preview.changes.length > 0 && (
<Button
variant="primary"
size="sm"
disabled={busy}
onClick={() => void save(Object.fromEntries(preview.changes.map((c) => [c.key, c.to])))}
>
Apply
</Button>
)}
<Button size="sm" variant="ghost" onClick={() => setPreview(null)}>
Cancel
</Button>
</div>
</Card>
)}
{saved && (
<Card className="mt-4 border-ok/40 p-4" role="status">
{saved.changed.length === 0 ? (
<p>Nothing changed.</p>
) : (
<>
<p className="font-medium text-ok">Saved.</p>
<p className="mt-1 text-sm text-muted">
{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."}
</p>
{running && saved.restart && (
<div className="mt-3 flex gap-2">
<Button size="sm" variant="primary" disabled={busy} onClick={() => void restartNow()}>
Restart now
</Button>
<Button size="sm" variant="ghost" onClick={() => setSaved(null)}>
Later
</Button>
</div>
)}
</>
)}
</Card>
)}
{saveErr && (
<Card className="mt-4 border-bad/40 p-4" role="alert">
<p className="font-medium text-bad">{saveErr.human.title}</p>
<p className="mt-1 text-sm text-muted">Nothing was saved. Fix the highlighted settings and try again.</p>
</Card>
)}
<div className="mt-6 flex flex-wrap gap-1 border-b border-line" role="tablist">
{view.categories
.filter((c) => basicCount(c.id) + advancedCount(c.id) > 0)
.map((c) => (
<button
key={c.id}
role="tab"
aria-selected={category === c.id}
onClick={() => setCategory(c.id)}
className={cn(
"-mb-px cursor-pointer border-b-2 px-3 py-2 text-sm",
category === c.id ? "border-gold text-ink" : "border-transparent text-muted hover:text-ink",
)}
>
{c.title}
</button>
))}
</div>
<div className="divide-y divide-line">
{basic.map((s) => (
<Row
key={s.key}
s={s}
value={valueOf(s)}
error={errors[s.key] ?? null}
onChange={(v) => setDraft((d) => ({ ...d, [s.key]: v }))}
onReset={() => setDraft((d) => ({ ...d, [s.key]: s.default }))}
/>
))}
</div>
{advanced.length > 0 && (
<div className="mt-4">
<button
onClick={() => setShowAdvanced((v) => !v)}
aria-expanded={showAdvanced}
className="flex cursor-pointer items-center gap-1 text-sm text-muted hover:text-ink"
>
{showAdvanced ? <ChevronDown className="h-4 w-4" aria-hidden /> : <ChevronRight className="h-4 w-4" aria-hidden />}
Advanced settings ({advanced.length})
</button>
{showAdvanced && (
<div className="divide-y divide-line">
{advanced.map((s) => (
<Row
key={s.key}
s={s}
value={valueOf(s)}
error={errors[s.key] ?? null}
onChange={(v) => setDraft((d) => ({ ...d, [s.key]: v }))}
onReset={() => setDraft((d) => ({ ...d, [s.key]: s.default }))}
/>
))}
</div>
)}
</div>
)}
{view.unknown_keys > 0 && (
<p className="mt-6 text-xs text-muted">{view.unknown_keys} other setting(s) in the configuration file are kept exactly as they are.</p>
)}
{dirtyKeys.length > 0 && (
<div className="fixed bottom-0 left-60 right-0 flex items-center justify-between border-t border-line bg-[#0b0c0e]/95 px-10 py-3">
<span className="text-sm text-muted">{dirtyKeys.length} unsaved change{dirtyKeys.length === 1 ? "" : "s"}</span>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => { setDraft({}); setErrors({}); }}>
Discard
</Button>
<Button variant="primary" size="sm" disabled={busy} onClick={() => void save(Object.fromEntries(dirtyKeys.map((k) => [k, draft[k]])))}>
Save changes
</Button>
</div>
</div>
)}
</div>
);
}
+3
View File
@@ -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: {
<main className="h-full flex-1 overflow-y-auto px-10 py-8">
{page === "overview" ? (
<Overview key={server.id} server={server} onForget={() => props.onForget(server.id)} />
) : page === "bots" || page === "server" ? (
<SettingsPage key={`${server.id}-${page}`} serverId={server.id} scope={page} title={current.label} question={current.question} />
) : (
<Placeholder title={current.label} question={current.question} />
)}
+14 -1
View File
@@ -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();
}
+117
View File
@@ -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")
+83
View File
@@ -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")