mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
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:
1 parent
19024a3162
commit
5b5255cca8
18 files changed
+3668
-3
No files matched your search
@@ -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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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", ¤t)?;
|
||||||
|
for f in &info.files {
|
||||||
|
fsx::atomic_write(Path::new(&f.original), &fs::read(dir.join(&f.stored))?)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct PresetChange {
|
||||||
|
pub key: String,
|
||||||
|
pub title: String,
|
||||||
|
pub from: Value,
|
||||||
|
pub to: Value,
|
||||||
|
pub dangerous: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct PresetPreview {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub changes: Vec<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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,15 @@ pub enum Error {
|
|||||||
UnknownInstallation(String),
|
UnknownInstallation(String),
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Invalid(String),
|
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.
|
/// 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::InsufficientSpace { .. } => ErrorCode::DiskFull,
|
||||||
Error::HashMismatch { .. } => ErrorCode::HashMismatch,
|
Error::HashMismatch { .. } => ErrorCode::HashMismatch,
|
||||||
Error::PathRejected(_) => ErrorCode::PathRejected,
|
Error::PathRejected(_) => ErrorCode::PathRejected,
|
||||||
|
Error::Validation(_) => ErrorCode::InvalidConfigValue,
|
||||||
_ => ErrorCode::Unknown,
|
_ => ErrorCode::Unknown,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,10 +149,15 @@ impl Error {
|
|||||||
pub struct UiError {
|
pub struct UiError {
|
||||||
pub human: Human,
|
pub human: Human,
|
||||||
pub technical: String,
|
pub technical: String,
|
||||||
|
pub fields: Vec<FieldError>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Error> for UiError {
|
impl From<Error> for UiError {
|
||||||
fn from(e: Error) -> Self {
|
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,3 +1,4 @@
|
|||||||
|
pub mod config;
|
||||||
pub mod driver;
|
pub mod driver;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod fsx;
|
pub mod fsx;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
@@ -5,6 +5,7 @@ use std::collections::HashSet;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use coa_core::config::{self, Scope, SettingsView};
|
||||||
use coa_core::driver::{self, DriverOutcome, Verb};
|
use coa_core::driver::{self, DriverOutcome, Verb};
|
||||||
use coa_core::error::UiError;
|
use coa_core::error::UiError;
|
||||||
use coa_core::layout::{self, Classification, ScanReport};
|
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::registry::{metadata_dir_for, InstallKind, InstallMeta, MetaDir, Registry};
|
||||||
use coa_core::{Error, Result};
|
use coa_core::{Error, Result};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
struct AppState {
|
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
|
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() {
|
pub fn run() {
|
||||||
let dir = data_dir();
|
let dir = data_dir();
|
||||||
let _ = coa_core::logging::init(&dir.join("logs").join("manager.log"));
|
let _ = coa_core::logging::init(&dir.join("logs").join("manager.log"));
|
||||||
@@ -175,7 +250,13 @@ pub fn run() {
|
|||||||
forget_server,
|
forget_server,
|
||||||
server_status,
|
server_status,
|
||||||
start_server,
|
start_server,
|
||||||
stop_server
|
stop_server,
|
||||||
|
get_settings,
|
||||||
|
save_settings,
|
||||||
|
list_presets,
|
||||||
|
preview_preset,
|
||||||
|
list_config_snapshots,
|
||||||
|
restore_config_snapshot
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running CoA Server Manager");
|
.expect("error while running CoA Server Manager");
|
||||||
|
|||||||
@@ -68,9 +68,79 @@ export interface ServerSummary {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FieldError {
|
||||||
|
key: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UiError {
|
export interface UiError {
|
||||||
human: Human;
|
human: Human;
|
||||||
technical: string;
|
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 = {
|
export const api = {
|
||||||
@@ -82,6 +152,10 @@ export const api = {
|
|||||||
status: (id: string) => invoke<StatusView>("server_status", { id }),
|
status: (id: string) => invoke<StatusView>("server_status", { id }),
|
||||||
start: (id: string) => invoke<DriverOutcome>("start_server", { id }),
|
start: (id: string) => invoke<DriverOutcome>("start_server", { id }),
|
||||||
stop: (id: string) => invoke<DriverOutcome>("stop_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 {
|
export function asUiError(e: unknown): UiError {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { type ServerSummary } from "@/lib/api";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Overview } from "@/screens/Overview";
|
import { Overview } from "@/screens/Overview";
|
||||||
import { Placeholder } from "@/screens/Placeholder";
|
import { Placeholder } from "@/screens/Placeholder";
|
||||||
|
import { SettingsPage } from "@/screens/SettingsPage";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
type Page = "overview" | "bots" | "server" | "players" | "friends" | "backups" | "console" | "settings";
|
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">
|
<main className="h-full flex-1 overflow-y-auto px-10 py-8">
|
||||||
{page === "overview" ? (
|
{page === "overview" ? (
|
||||||
<Overview key={server.id} server={server} onForget={() => props.onForget(server.id)} />
|
<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} />
|
<Placeholder title={current.label} question={current.question} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+14
-1
@@ -17,8 +17,21 @@ export function evaluate(expression) {
|
|||||||
ws.send(JSON.stringify({ id: n, method: "Runtime.evaluate", params: { expression, awaitPromise: true, returnByValue: true } }));
|
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]);
|
const r = await evaluate(process.argv[2]);
|
||||||
console.log(JSON.stringify(r.result?.result?.value ?? r.result, null, 2));
|
console.log(JSON.stringify(r.result?.result?.value ?? r.result, null, 2));
|
||||||
ws.close();
|
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();
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
Reference in new issue
Block a user