From 2eea0e117c0505fe664769bea1cdf17a97299afe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 4 Oct 2026 20:26:39 +0500 Subject: [PATCH] Add translated module settings and exclusive SQUID Playerbots controls --- changelog.d/20261004-auction-item-types.md | 7 + changelog.d/20261004-module-settings.md | 7 + changelog.d/20261004-squid-playerbots.md | 7 + crates/coa-core/src/backup.rs | 5 +- crates/coa-core/src/db.rs | 1 + crates/coa-core/src/driver.rs | 2 + crates/coa-core/src/error.rs | 5 + crates/coa-core/src/modules.rs | 225 ++++++++++++++++++--- crates/coa-core/src/multiworld.rs | 4 + modules/catalog.json | 16 +- src/dev/mock.ts | 16 +- src/i18n/locales/de.ts | 10 + src/i18n/locales/en.ts | 10 + src/i18n/locales/es.ts | 10 + src/i18n/locales/fr.ts | 10 + src/i18n/locales/ru.ts | 10 + src/i18n/locales/zh.ts | 10 + src/lib/api.ts | 1 + src/lib/moduleSettings.ts | 187 +++++++++++++++++ src/screens/AuctionTypes.tsx | 50 +++++ src/screens/ModulesPage.tsx | 104 ++++++++-- tools/check-module-settings.mjs | 42 ++++ 22 files changed, 683 insertions(+), 56 deletions(-) create mode 100644 changelog.d/20261004-auction-item-types.md create mode 100644 changelog.d/20261004-module-settings.md create mode 100644 changelog.d/20261004-squid-playerbots.md create mode 100644 src/lib/moduleSettings.ts create mode 100644 src/screens/AuctionTypes.tsx create mode 100644 tools/check-module-settings.mjs diff --git a/changelog.d/20261004-auction-item-types.md b/changelog.d/20261004-auction-item-types.md new file mode 100644 index 0000000..aad9d5d --- /dev/null +++ b/changelog.d/20261004-auction-item-types.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Choose which item types the auction bot sells +--- +Switch 15 item categories on or off and adjust listing frequency by quality. Labels and descriptions are translated into all six languages. Changes affect new listings; existing auctions remain. diff --git a/changelog.d/20261004-module-settings.md b/changelog.d/20261004-module-settings.md new file mode 100644 index 0000000..0dfb7d1 --- /dev/null +++ b/changelog.d/20261004-module-settings.md @@ -0,0 +1,7 @@ +--- +area: manager +type: changed +audience: admins +title: Module settings now offer translated controls and an enemy damage slider +--- +Configure scaling, auctions and enchantments with translated controls. Module switches no longer appear twice. Modules without settings hide their settings button; short lists need no search. New server builds support dungeon and raid damage from 25% to 200%. diff --git a/changelog.d/20261004-squid-playerbots.md b/changelog.d/20261004-squid-playerbots.md new file mode 100644 index 0000000..3fd45e4 --- /dev/null +++ b/changelog.d/20261004-squid-playerbots.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Configure SQUID Playerbots and prevent conflicting bot systems +--- +SQUID Playerbots has translated controls for its existing settings. Enable one bot system at a time; the Manager checks manual config changes before starting the server. Recovery points also include the SQUID database when installed. diff --git a/crates/coa-core/src/backup.rs b/crates/coa-core/src/backup.rs index 3a77883..e612dc0 100644 --- a/crates/coa-core/src/backup.rs +++ b/crates/coa-core/src/backup.rs @@ -203,6 +203,9 @@ pub fn create(root: &Path, meta: &Path, kind: Kind, trigger: Trigger, label: Opt databases.push((format!("{}-characters", other.name()), other.schema("characters")?)); if kind == Kind::Full { databases.push((format!("{}-world", other.name()), other.schema("world")?)); } } + if kind != Kind::Config && with_database(root, |db| db.schema_exists("acore_playerbots"))? { + databases.push(("playerbots".into(), "acore_playerbots")); + } for (name, schema) in databases { progress(&format!("Backing up {name} database")); let component = with_database(root, |db| { @@ -341,7 +344,7 @@ pub fn restore_database(root: &Path, meta: &Path, id: &str, name: &str) -> Resul if now.world.state != ServiceState::Stopped || now.auth.state != ServiceState::Stopped || crate::multiworld::is_running(root) { return Err(Error::Invalid("Stop the server before restoring a database.".into())); } - let live = if name.contains('-') { db::schema_of(name)? } else { point.realm.schema(name)? }; + let live = if name.contains('-') || name == "playerbots" { db::schema_of(name)? } else { point.realm.schema(name)? }; let expected_tables = comp.tables.unwrap_or(0); let dump = point_dir(meta, id)?.join(&comp.path); let stamp = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string(); diff --git a/crates/coa-core/src/db.rs b/crates/coa-core/src/db.rs index c182e6a..6481e7e 100644 --- a/crates/coa-core/src/db.rs +++ b/crates/coa-core/src/db.rs @@ -68,6 +68,7 @@ fn no_window(cmd: &mut Command) { pub const SCHEMAS: [(&str, &str); 3] = [("characters", "acore_characters"), ("auth", "acore_auth"), ("world", "acore_world")]; pub fn schema_of(kind: &str) -> Result<&'static str> { + if kind == "playerbots" { return Ok("acore_playerbots"); } if let Some(kind) = kind.strip_prefix("wildcard-") { return crate::realms::Mode::Wildcard.schema(kind); } if let Some(kind) = kind.strip_prefix("coa-") { return crate::realms::Mode::Coa.schema(kind); } SCHEMAS.iter().find(|(k, _)| *k == kind).map(|(_, s)| *s).ok_or_else(|| Error::Invalid(format!("unknown database {kind}"))) diff --git a/crates/coa-core/src/driver.rs b/crates/coa-core/src/driver.rs index d51212b..7ab1073 100644 --- a/crates/coa-core/src/driver.rs +++ b/crates/coa-core/src/driver.rs @@ -82,6 +82,7 @@ fn launcher(root: &Path) -> Result<(PathBuf, PathBuf)> { /// Run a launcher verb to completion (blocking; call from a worker thread). pub fn run(root: &Path, verb: Verb) -> Result { let root = fsx::canonicalize_lenient(root)?; + if matches!(verb, Verb::StartAll | Verb::StartWorld) { crate::modules::ensure_bot_exclusivity(&root)?; } if crate::docker::is_docker(&root) { return crate::docker::run(&root, verb); } @@ -97,6 +98,7 @@ pub fn run(root: &Path, verb: Verb) -> Result { crate::realms::setup_realmlist(&root)?; } } + if matches!(verb, Verb::StartAll | Verb::StartWorld) { crate::modules::ensure_bot_exclusivity(&root)?; } let (python, script) = launcher(&root)?; let mut cmd = Command::new(&python); cmd.arg("-B").arg(&script).arg(verb.arg()).current_dir(&root).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); diff --git a/crates/coa-core/src/error.rs b/crates/coa-core/src/error.rs index 369fa0d..f2b236c 100644 --- a/crates/coa-core/src/error.rs +++ b/crates/coa-core/src/error.rs @@ -26,6 +26,8 @@ pub enum Error { NetworkUnreachable(String), #[error("the bot module found no level 80 template characters")] CompanionTemplatesMissing, + #[error("CoA Companions and SQUID Playerbots cannot both be enabled. Disable one bot module.")] + BotModulesConflict, #[error("this server's bot module does not have that command yet")] CompanionCommandUnsupported, #[error("invalid values: {}", .0.iter().map(|f| format!("{}: {}", f.key, f.message)).collect::>().join("; "))] @@ -58,6 +60,7 @@ pub enum ErrorCode { PackageNotPublished, NetworkUnreachable, CompanionTemplatesMissing, + BotModulesConflict, CompanionCommandUnsupported, Unknown, } @@ -84,6 +87,7 @@ impl ErrorCode { pub fn human(self) -> Human { use FixAction::*; let (title, message, actions): (&str, &str, &'static [FixAction]) = match self { + ErrorCode::BotModulesConflict => ("Two bot modules are enabled", "Disable CoA Companions or SQUID Playerbots in Modules before starting the server.", &[ShowDetails]), ErrorCode::DatabaseNotRunning => ( "Database isn't running", "The server needs its database to start. Start the database first.", @@ -179,6 +183,7 @@ impl Error { Error::Validation(_) => ErrorCode::InvalidConfigValue, Error::PackageNotPublished(_) => ErrorCode::PackageNotPublished, Error::NetworkUnreachable(_) => ErrorCode::NetworkUnreachable, + Error::BotModulesConflict => ErrorCode::BotModulesConflict, Error::CompanionTemplatesMissing => ErrorCode::CompanionTemplatesMissing, Error::CompanionCommandUnsupported => ErrorCode::CompanionCommandUnsupported, _ => ErrorCode::Unknown, diff --git a/crates/coa-core/src/modules.rs b/crates/coa-core/src/modules.rs index d340770..a18f228 100644 --- a/crates/coa-core/src/modules.rs +++ b/crates/coa-core/src/modules.rs @@ -91,6 +91,8 @@ pub struct ModuleView { pub repo: String, /// The module's configuration is present on this server (otherwise it is not part of this server build). pub installed: bool, + /// At least one editable setting besides the master switch exists in this server build. + pub has_settings: bool, pub enabled: bool, pub switchable: bool, pub status: String, @@ -125,9 +127,15 @@ fn list_in(cat: &[Entry], root: &Path) -> Vec { let conf = dir(root).join(&name); let dist = dir(root).join(format!("{name}.dist")); let installed = conf.is_file() || dist.is_file(); + let has_settings = installed && settings_in(cat, root, &e.id) + .map(|settings| settings.iter().any(|s| s.key != e.enable_key)) + .unwrap_or(false); // the active file decides; without one the documented default is what the server will use - let enabled = !e.switchable - || read(&conf).or_else(|| read(&dist)).and_then(|c| c.get(&e.enable_key).map(truthy)).unwrap_or(e.default_on); + let enabled = if matches!(e.id.as_str(), "companions" | "playerbots") { + bot_enabled(root, &e).unwrap_or(true) + } else { + !e.switchable || read(&conf).or_else(|| read(&dist)).and_then(|c| c.get(&e.enable_key).map(truthy)).unwrap_or(e.default_on) + }; let blocked = wildcard && matches!(e.id.as_str(), "companions" | "playerbots"); ModuleView { compatibility: if blocked { "unsupported" } else if wildcard && e.id != "client-compat" { "experimental" } else { "compatible" }.into(), @@ -136,6 +144,7 @@ fn list_in(cat: &[Entry], root: &Path) -> Vec { description: e.description, repo: e.repo, installed, + has_settings, enabled: enabled && !blocked, switchable: e.switchable && !blocked, status: e.status, @@ -167,16 +176,57 @@ fn backup(meta: &Path, conf: &Path) -> Result<()> { fsx::atomic_write(&to, &fs::read(conf)?) } +fn require_bot_server_stopped(root: &Path) -> Result<()> { + let observed = crate::process::observe(root, &crate::layout::read_ports(root)); + if observed.world.state != crate::process::ServiceState::Stopped || crate::multiworld::is_running(root) { + return Err(Error::Invalid("Stop the server before changing the bot system.".into())); + } + Ok(()) +} + pub fn set_enabled(root: &Path, meta: &Path, id: &str, on: bool) -> Result<()> { + let _lock = if matches!(id, "companions" | "playerbots") { + require_bot_server_stopped(root)?; + fs::create_dir_all(root.join(".state"))?; + let lock = fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(root.join(".state/control.lock"))?; + fs4::fs_std::FileExt::try_lock_exclusive(&lock).map_err(|_| Error::Invalid("Another start/stop action is in progress.".into()))?; + require_bot_server_stopped(root)?; + Some(lock) + } else { None }; if on { crate::realms::guard_module(root, id)?; } set_enabled_in(&catalog(), root, meta, id, on) } +fn bot_enabled(root: &Path, e: &Entry) -> Result { + let name = conf_name(root, e); + let conf = dir(root).join(&name); + let dist = dir(root).join(format!("{name}.dist")); + let path = if conf.exists() { conf } else { dist }; + if !path.exists() { return Ok(false); } + let file = ConfFile::parse_bytes(&fs::read(path)?)?; + // Invalid booleans use the modules' enabled compiled default: fail closed. + Ok(file.get(&e.enable_key).map(|v| !matches!(v.trim().trim_matches('"').to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off")).unwrap_or(true)) +} + +pub fn ensure_bot_exclusivity(root: &Path) -> Result<()> { + let cat = catalog(); + if bot_enabled(root, &entry_in(&cat, "companions")?)? && bot_enabled(root, &entry_in(&cat, "playerbots")?)? { + return Err(Error::BotModulesConflict); + } + Ok(()) +} + fn set_enabled_in(cat: &[Entry], root: &Path, meta: &Path, id: &str, on: bool) -> Result<()> { let e = entry_in(cat, id)?; if !e.switchable || e.enable_key.is_empty() { return Err(Error::Invalid("This part cannot be switched off.".into())); } + if on && matches!(id, "companions" | "playerbots") { + let other = if id == "companions" { "playerbots" } else { "companions" }; + if let Some(other) = cat.iter().find(|e| e.id == other) { + if bot_enabled(root, other)? { return Err(Error::BotModulesConflict); } + } + } let conf = active_file(root, &e)?; let mut file = ConfFile::parse_bytes(&fs::read(&conf)?)?; if file.get(&e.enable_key).map(truthy).unwrap_or(e.default_on) == on && file.get(&e.enable_key).is_some() { @@ -209,7 +259,15 @@ fn settings_in(cat: &[Entry], root: &Path, id: &str) -> Result> { let conf = dir(root).join(&name); let dist = read(&dir(root).join(format!("{name}.dist"))); let active = read(&conf).or_else(|| dist.clone()).ok_or_else(|| Error::Invalid("This module is not part of this server.".into()))?; - Ok(active + let mut effective = active.clone(); + if let Some(defaults) = &dist { + for (key, value) in defaults.entries() { + if effective.get(key).is_none() { + effective.set(key, value, &[]); + } + } + } + Ok(effective .entries() .map(|(k, v)| Setting { key: k.to_string(), @@ -230,13 +288,30 @@ fn save_settings_in(cat: &[Entry], root: &Path, meta: &Path, id: &str, changes: let e = entry_in(cat, id)?; let conf = active_file(root, &e)?; let mut file = ConfFile::parse_bytes(&fs::read(&conf)?)?; + let dist = read(&dir(root).join(format!("{}.dist", conf_name(root, &e)))); let mut changed = Vec::new(); for (key, value) in changes { let value = value.trim(); - if file.get(key).is_none() { + if e.switchable && key == &e.enable_key { + return Err(Error::Invalid("Use the module switch to turn this module on or off.".into())); + } + if id == "content-scaling" && key == "CoAContentScaling.Difficulty.DamageMultiplier" { + let multiplier = value.parse::().map_err(|_| Error::Invalid("Enemy damage must be a number from 0.25 to 2.0.".into()))?; + if !multiplier.is_finite() || !(0.25..=2.0).contains(&multiplier) { + return Err(Error::Invalid("Enemy damage must be a number from 0.25 to 2.0.".into())); + } + } + if id == "ah-bot" && key.starts_with("AuctionHouseBot.ListProportion.Category") && key.contains(".Quality") { + let weight = value.parse::().map_err(|_| Error::Invalid("Auction listing weights must be whole numbers from 0 to 1000.".into()))?; + if weight > 1000 { + return Err(Error::Invalid("Auction listing weights must be whole numbers from 0 to 1000.".into())); + } + } + if file.get(key).is_none() && dist.as_ref().and_then(|d| d.get(key)).is_none() { return Err(Error::Invalid(format!("{key} is not a setting of this module."))); } - if value.is_empty() || value.len() > 500 || value.chars().any(char::is_control) { + let documented_empty = dist.as_ref().and_then(|d| d.get(key)).map(str::trim) == Some(""); + if (value.is_empty() && !documented_empty) || value.len() > 500 || value.chars().any(char::is_control) { return Err(Error::Invalid(format!("The value of {key} must be one line of text."))); } if file.get(key).map(str::trim) != Some(value) { @@ -286,6 +361,40 @@ mod tests { (root, meta) } + #[test] + fn bot_systems_are_mutually_exclusive_in_both_directions_and_manual_configs() { + let d = tempfile::tempdir().unwrap(); + let root = d.path(); + fs::create_dir_all(dir(root)).unwrap(); + let a = dir(root).join("mod_coa_playerbots.conf"); + let b = dir(root).join("playerbots.conf"); + fs::write(&a, "CoaBots.Enable = 1\n").unwrap(); + fs::write(&b, "AiPlayerbot.Enabled = 0\n").unwrap(); + assert!(ensure_bot_exclusivity(root).is_ok()); + assert!(matches!(set_enabled_in(&catalog(), root, root, "playerbots", true), Err(Error::BotModulesConflict))); + assert_eq!(fs::read_to_string(&b).unwrap(), "AiPlayerbot.Enabled = 0\n"); + fs::write(&a, "CoaBots.Enable = 0\n").unwrap(); + set_enabled_in(&catalog(), root, root, "playerbots", true).unwrap(); + assert!(matches!(set_enabled_in(&catalog(), root, root, "companions", true), Err(Error::BotModulesConflict))); + fs::write(&a, "CoaBots.Enable = true\n").unwrap(); + assert!(matches!(ensure_bot_exclusivity(root), Err(Error::BotModulesConflict))); + fs::write(&b, "# missing key uses the compiled enabled default\n").unwrap(); + assert!(matches!(ensure_bot_exclusivity(root), Err(Error::BotModulesConflict))); + set_enabled_in(&catalog(), root, root, "companions", false).unwrap(); + assert!(ensure_bot_exclusivity(root).is_ok()); + } + + #[test] + fn startup_rejects_conflicting_bots_before_launching_any_service() { + let d = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir(d.path())).unwrap(); + fs::write(dir(d.path()).join("mod_coa_playerbots.conf"), "CoaBots.Enable = 1").unwrap(); + fs::write(dir(d.path()).join("playerbots.conf"), "AiPlayerbot.Enabled = 1").unwrap(); + for verb in [crate::driver::Verb::StartAll, crate::driver::Verb::StartWorld] { + assert!(matches!(crate::driver::run(d.path(), verb), Err(Error::BotModulesConflict))); + } + } + #[test] fn the_bundled_catalog_is_valid() { let c = catalog(); @@ -293,7 +402,7 @@ mod tests { for e in &c { assert!(e.description.contains_key("en") && e.description.len() == 5, "{} has all five descriptions", e.id); assert!(e.repo.starts_with("https://github.com/") && e.conf.ends_with(".conf"), "{}", e.id); - assert!(!e.switchable || e.enable_key.ends_with(".Enable"), "{} needs the setting that switches it", e.id); + assert!(!e.switchable || (e.enable_key.ends_with(".Enable") || e.enable_key.ends_with(".Enabled")), "{} needs the setting that switches it", e.id); assert!(["soon", "early", "beta", "release"].contains(&e.status.as_str()), "{} has an unknown status", e.id); assert!(e.status != "soon" || !e.switchable, "{} is only announced, so it cannot be switched", e.id); } @@ -312,25 +421,6 @@ mod tests { assert!(!list_in(&test_catalog(), &root).into_iter().find(|m| m.id == "spellbook").unwrap().installed, "no configuration of that module on this server"); } - #[test] - fn the_content_scaling_switch_persists_the_master_flag_and_preserves_other_settings() { - let d = tempfile::tempdir().unwrap(); - let (root, meta) = server(d.path()); - let e = entry_in(&catalog(), "content-scaling").unwrap(); - assert_eq!(e.enable_key, "CoAContentScaling.Enable"); - let defaults = "CoAContentScaling.Enable = 1\nCoAContentScaling.ScaleItems = 1\nCoAContentScaling.GroupScaling.Enable = 1\n"; - fs::write(dir(&root).join(format!("{}.dist", e.conf)), defaults).unwrap(); - set_enabled(&root, &meta, &e.id, false).unwrap(); - let active = read(&dir(&root).join(&e.conf)).unwrap(); - assert_eq!(active.get("CoAContentScaling.Enable"), Some("0")); - assert_eq!(active.get("CoAContentScaling.ScaleItems"), Some("1")); - assert_eq!(active.get("CoAContentScaling.GroupScaling.Enable"), Some("1")); - assert!(!list(&root).into_iter().find(|m| m.id == e.id).unwrap().enabled); - set_enabled(&root, &meta, &e.id, true).unwrap(); - assert_eq!(read(&dir(&root).join(&e.conf)).unwrap().get("CoAContentScaling.Enable"), Some("1")); - assert!(list(&root).into_iter().find(|m| m.id == e.id).unwrap().enabled); - } - #[test] fn switching_a_module_creates_its_file_saves_the_old_one_and_changes_only_that_line() { let d = tempfile::tempdir().unwrap(); @@ -367,6 +457,91 @@ mod tests { assert!(!fs::read_to_string(dir(&root).join("war_games.conf")).unwrap().contains("Injected")); } + #[test] + fn updated_module_defaults_are_visible_and_can_be_saved_in_an_existing_config() { + let d = tempfile::tempdir().unwrap(); + let (root, meta) = server(d.path()); + fs::write(dir(&root).join("war_games.conf"), "WarGames.Enable = 0\n").unwrap(); + let cat = test_catalog(); + let values = settings_in(&cat, &root, "war-games").unwrap(); + assert_eq!(values.iter().find(|s| s.key == "WarGames.Enable").unwrap().value, "0"); + assert_eq!(values.iter().find(|s| s.key == "WarGames.ChallengeSeconds").unwrap().value, "60"); + let changes = BTreeMap::from([("WarGames.ChallengeSeconds".to_string(), "90".to_string())]); + save_settings_in(&cat, &root, &meta, "war-games", &changes).unwrap(); + let text = fs::read_to_string(dir(&root).join("war_games.conf")).unwrap(); + assert!(text.contains("WarGames.Enable = 0") && text.contains("WarGames.ChallengeSeconds = 90")); + } + + #[test] + fn module_enable_cannot_be_changed_through_the_settings_editor() { + let d = tempfile::tempdir().unwrap(); + let (root, meta) = server(d.path()); + let changes = BTreeMap::from([("WarGames.Enable".into(), "0".into())]); + assert!(save_settings_in(&test_catalog(), &root, &meta, "war-games", &changes).is_err()); + assert_eq!(settings_in(&test_catalog(), &root, "war-games").unwrap()[0].value, "1"); + } + + #[test] + fn settings_button_requires_more_than_the_master_switch() { + let d = tempfile::tempdir().unwrap(); + let (root, _) = server(d.path()); + let cat = test_catalog(); + assert!(list_in(&cat, &root).iter().find(|m| m.id == "war-games").unwrap().has_settings); + fs::write(dir(&root).join("war_games.conf.dist"), "WarGames.Enable = 1\n").unwrap(); + assert!(!list_in(&cat, &root).iter().find(|m| m.id == "war-games").unwrap().has_settings); + fs::write(dir(&root).join("war_games.conf"), "WarGames.Enable = 0\nWarGames.ChallengeSeconds = 90\n").unwrap(); + assert!(list_in(&cat, &root).iter().find(|m| m.id == "war-games").unwrap().has_settings, "custom active settings remain editable"); + } + + #[test] + fn documented_empty_lists_can_be_restored() { + let d = tempfile::tempdir().unwrap(); + let (root, meta) = server(d.path()); + fs::write(dir(&root).join("war_games.conf.dist"), "WarGames.Enable = 1\nWarGames.ExcludedItems = \n").unwrap(); + fs::write(dir(&root).join("war_games.conf"), "WarGames.Enable = 1\nWarGames.ExcludedItems = 42\n").unwrap(); + let changes = BTreeMap::from([("WarGames.ExcludedItems".into(), "".into())]); + save_settings_in(&test_catalog(), &root, &meta, "war-games", &changes).unwrap(); + assert_eq!(settings_in(&test_catalog(), &root, "war-games").unwrap().iter().find(|s| s.key == "WarGames.ExcludedItems").unwrap().value, ""); + } + + #[test] + fn auction_type_weights_are_validated_before_any_setting_is_written() { + let d = tempfile::tempdir().unwrap(); + let (root, meta) = server(d.path()); + let cat = catalog(); + let key = "AuctionHouseBot.ListProportion.CategoryWeapon.QualityNormal"; + let conf = dir(&root).join("mod_ahbot.conf"); + fs::write(&conf, format!("AuctionHouseBot.Enable = true\nAuctionHouseBot.ItemsPerCycle = 150\n{key} = 20\n")).unwrap(); + let original = fs::read(&conf).unwrap(); + for bad in ["-1", "1.5", "NaN", "1001", "", "4294967296"] { + let changes = BTreeMap::from([("AuctionHouseBot.ItemsPerCycle".into(), "25".into()), (key.into(), bad.into())]); + assert!(save_settings_in(&cat, &root, &meta, "ah-bot", &changes).is_err(), "{bad}"); + assert_eq!(fs::read(&conf).unwrap(), original, "no partial save for {bad}"); + } + let off = BTreeMap::from([(key.into(), "0".into())]); + save_settings_in(&cat, &root, &meta, "ah-bot", &off).unwrap(); + assert_eq!(settings_in(&cat, &root, "ah-bot").unwrap().iter().find(|s| s.key == key).unwrap().value, "0"); + } + + #[test] + fn damage_difficulty_is_validated_and_older_server_builds_do_not_accept_it() { + let d = tempfile::tempdir().unwrap(); + let (root, meta) = server(d.path()); + let cat = catalog(); + let conf = dir(&root).join("mod-coa-content-scaling.conf.dist"); + fs::write(&conf, "CoAContentScaling.Enable = 1\n").unwrap(); + let key = "CoAContentScaling.Difficulty.DamageMultiplier"; + let changes = |v: &str| BTreeMap::from([(key.into(), v.into())]); + assert!(save_settings_in(&cat, &root, &meta, "content-scaling", &changes("0.5")).is_err()); + fs::write(&conf, format!("CoAContentScaling.Enable = 1\n{key} = 1.0\n")).unwrap(); + for bad in ["NaN", "inf", "0", "0.24", "2.01", "abc"] { + assert!(save_settings_in(&cat, &root, &meta, "content-scaling", &changes(bad)).is_err(), "{bad}"); + } + assert_eq!(save_settings_in(&cat, &root, &meta, "content-scaling", &changes("0.25")).unwrap(), [key]); + assert_eq!(save_settings_in(&cat, &root, &meta, "content-scaling", &changes("2")).unwrap(), [key]); + assert_eq!(settings_in(&cat, &root, "content-scaling").unwrap().iter().find(|s| s.key == key).unwrap().value, "2"); + } + #[test] fn an_older_file_name_is_used_when_the_current_one_is_missing_and_a_part_that_cannot_be_switched_stays_on() { let d = tempfile::tempdir().unwrap(); diff --git a/crates/coa-core/src/multiworld.rs b/crates/coa-core/src/multiworld.rs index 526f928..15fcfca 100644 --- a/crates/coa-core/src/multiworld.rs +++ b/crates/coa-core/src/multiworld.rs @@ -64,6 +64,10 @@ fn prepare(main: &Path) -> Result { let mode = if s.active == Mode::Coa { Mode::Wildcard } else { Mode::Coa }; let second = fsx::ensure_within(main, &root(main))?; for folder in ["Runtime", "BugReport", "mysql/bin"] { copy_tree(&main.join(folder), &fsx::ensure_within(&second, &second.join(folder))?)?; } + if main.join("Scripts/squid_playerbots.py").is_file() { + fs::create_dir_all(second.join("Scripts"))?; + fs::copy(main.join("Scripts/squid_playerbots.py"), second.join("Scripts/squid_playerbots.py"))?; + } if main.join("Core/reference").is_dir() { copy_tree(&main.join("Core/reference"), &fsx::ensure_within(&second, &second.join("Core/reference"))?)?; } for entry in fs::read_dir(main.join("Core"))? { let entry = entry?; diff --git a/modules/catalog.json b/modules/catalog.json index 810a4a4..5ad4ff1 100644 --- a/modules/catalog.json +++ b/modules/catalog.json @@ -76,10 +76,12 @@ "fr": "Remplit l'hôtel des ventes d'annonces réalistes et réglables.", "es": "Llena la casa de subastas con ofertas realistas y ajustables." }, - "repo": "https://github.com/NathanHandley/mod-ah-bot-plus", + "repo": "https://github.com/Corfirean/azerothcore-wotlk-coa/tree/coa-bots/modules/mod-ah-bot-plus", "conf": "mod_ahbot.conf", - "switchable": false, - "status": "soon", + "enable_key": "AuctionHouseBot.Enable", + "default_on": false, + "switchable": true, + "status": "early", "icon": "gavel" }, { @@ -112,9 +114,11 @@ }, "repo": "https://github.com/Zyth45/mod-playerbots", "conf": "playerbots.conf", - "switchable": false, - "status": "soon", - "icon": "swords" + "switchable": true, + "status": "early", + "icon": "swords", + "enable_key": "AiPlayerbot.Enabled", + "default_on": false }, { "id": "client-compat", diff --git a/src/dev/mock.ts b/src/dev/mock.ts index 7f19dcd..ba417f5 100644 --- a/src/dev/mock.ts +++ b/src/dev/mock.ts @@ -1,4 +1,5 @@ // Dev-only: lets the UI run in a plain browser for visual review. Never bundled in production builds. +import { auctionCategories, auctionQualities } from "../lib/moduleSettings"; import botsSchema from "../../schemas/bots.json"; import serverSchema from "../../schemas/server.json"; import botsPresets from "../../schemas/presets/bots.json"; @@ -9,6 +10,7 @@ export {}; let added = new URLSearchParams(location.search).has("demo"), running = false; let realm: "coa" | "wildcard" = "coa", wildcardCreated = false; let simultaneous = false; + const moduleEdits: Record> = {}; const modState: Record = { "companions": true, "content-scaling": true, "client-compat": true, "tbc-content": true, "wotlk-content": true, "npc-enchanter": true }; const report = {path:"C:\\games\\CoA-Repack",classification:"healthy",items:[ {key:"worldserver",label:"World server",status:"found",detail:null}, @@ -63,22 +65,24 @@ export {}; if(cmd==="realmlist_save"){const w=window as any;const rl=w.__rl;if(args.profileId){const p=rl.profiles.find((x: any)=>x.id===args.profileId);p.name=args.name;p.data=args.data;return p;}if(rl.profiles.some((x: any)=>x.name.toLowerCase()===String(args.name).toLowerCase()))throw {human:{code:"unknown",title:"",message:"",actions:[]},technical:"A realmlist with that name already exists."};const p={id:String(args.name).toLowerCase().replace(/[^a-z0-9]+/g,"-"),name:args.name,data:(/\s/.test(args.data.trim())?args.data.trim():"set realmlist "+args.data.trim())+"\r\n"};rl.profiles.push(p);return p;} if(cmd==="realmlist_delete"){const rl=(window as any).__rl;rl.profiles=rl.profiles.filter((x: any)=>x.id!==args.profileId);return null;} if(cmd==="account_delete") return null; - if(cmd==="modules_list") return [{id:"companions",name:"CoA Companions",description:{en:"Companion bots for groups, dungeons and your guild.",ru:"Боты-компаньоны для групп, подземелий и гильдии."},repo:"https://github.com/Corfirean/mod-coa-playerbots",installed:true,enabled:modState["companions"],switchable:true,status:"beta",icon:"bot",page:"bots"},{id:"content-scaling",name:"Content Scaling",description:{en:"Scales dungeons and raids to your group, with solo modes.",ru:"Подстраивает подземелья и рейды под вашу группу, есть соло-режимы."},repo:"https://github.com/Corfirean/mod-coa-content-scaling",installed:true,enabled:modState["content-scaling"],switchable:true,status:"early",icon:"scaling",page:null},{id:"tbc-content",name:"TBC Content",description:{en:"Outland dungeons and raids, scaled to your level range.",ru:"Подземелья и рейды Запределья, подогнанные под ваши уровни."},repo:"https://github.com/Corfirean/mod-coa-content-scaling/tree/main/content-packs/mod-coa-tbc-content",installed:true,enabled:modState["tbc-content"],switchable:true,status:"early",icon:"tbc",page:null},{id:"wotlk-content",name:"WotLK Content",description:{en:"Northrend dungeons and raids, scaled to your level range.",ru:"Подземелья и рейды Нордскола, подогнанные под ваши уровни."},repo:"https://github.com/Corfirean/mod-coa-content-scaling/tree/main/content-packs/mod-coa-wotlk-content",installed:true,enabled:modState["wotlk-content"],switchable:true,status:"early",icon:"wotlk",page:null},{id:"ah-bot",name:"Auction House Bot",description:{en:"Fills the auction house with realistic, adjustable listings.",ru:"Наполняет аукцион реалистичными настраиваемыми лотами."},repo:"https://github.com/NathanHandley/mod-ah-bot-plus",installed:false,enabled:false,switchable:false,status:"soon",icon:"gavel",page:null,hidden:false},{id:"npc-enchanter",name:"NPC Enchanter",description:{en:"An NPC that enchants your gear for you.",ru:"НПС, который зачаровывает ваше снаряжение."},repo:"https://github.com/azerothcore/mod-npc-enchanter",installed:true,enabled:modState["npc-enchanter"],switchable:true,status:"early",icon:"sparkles",page:null,hidden:false},{id:"playerbots",name:"SQUID's Playerbots",description:{en:"An alternative bot system with random bots and full AI.",ru:"Альтернативная система ботов со случайными ботами и полным ИИ."},repo:"https://github.com/Zyth45/mod-playerbots",installed:false,enabled:false,switchable:false,status:"soon",icon:"swords",page:null,hidden:false},{id:"client-compat",name:"Client compatibility",description:{en:"Client-server link and appearance unlocks.",ru:"Связь клиента с сервером и разблокировка внешних видов."},repo:"https://github.com/Corfirean/azerothcore-wotlk-coa",installed:true,enabled:modState["client-compat"],switchable:false,status:"release",icon:"plug",page:null,hidden:true}].map(m => ({...m, compatibility: realm === "wildcard" ? (["companions", "playerbots"].includes(m.id) ? "unsupported" : m.id === "client-compat" ? "compatible" : "experimental") : "compatible", switchable: realm === "wildcard" && ["companions", "playerbots"].includes(m.id) ? false : m.switchable})); - if(cmd==="module_set_enabled"){ modState[args.module]=args.enabled; return null; } + if(cmd==="modules_list") return [{id:"companions",name:"CoA Companions",description:{en:"Companion bots for groups, dungeons and your guild.",ru:"Боты-компаньоны для групп, подземелий и гильдии."},repo:"https://github.com/Corfirean/mod-coa-playerbots",installed:true,enabled:modState["companions"],switchable:true,status:"beta",icon:"bot",page:"bots"},{id:"content-scaling",name:"Content Scaling",description:{en:"Scales dungeons and raids to your group, with solo modes.",ru:"Подстраивает подземелья и рейды под вашу группу, есть соло-режимы."},repo:"https://github.com/Corfirean/mod-coa-content-scaling",installed:true,enabled:modState["content-scaling"],switchable:true,status:"early",icon:"scaling",page:null},{id:"tbc-content",name:"TBC Content",description:{en:"Outland dungeons and raids, scaled to your level range.",ru:"Подземелья и рейды Запределья, подогнанные под ваши уровни."},repo:"https://github.com/Corfirean/mod-coa-content-scaling/tree/main/content-packs/mod-coa-tbc-content",installed:true,enabled:modState["tbc-content"],switchable:true,status:"early",icon:"tbc",page:null},{id:"wotlk-content",name:"WotLK Content",description:{en:"Northrend dungeons and raids, scaled to your level range.",ru:"Подземелья и рейды Нордскола, подогнанные под ваши уровни."},repo:"https://github.com/Corfirean/mod-coa-content-scaling/tree/main/content-packs/mod-coa-wotlk-content",installed:true,enabled:modState["wotlk-content"],switchable:true,status:"early",icon:"wotlk",page:null},{id:"ah-bot",name:"Auction House Bot",description:{en:"Fills the auction house with realistic, adjustable listings.",ru:"Наполняет аукцион реалистичными настраиваемыми лотами."},repo:"https://github.com/Corfirean/azerothcore-wotlk-coa",installed:true,enabled:modState["ah-bot"] ?? false,switchable:true,status:"early",icon:"gavel",page:null,hidden:false},{id:"npc-enchanter",name:"NPC Enchanter",description:{en:"An NPC that enchants your gear for you.",ru:"НПС, который зачаровывает ваше снаряжение."},repo:"https://github.com/azerothcore/mod-npc-enchanter",installed:true,enabled:modState["npc-enchanter"],switchable:true,status:"early",icon:"sparkles",page:null,hidden:false},{id:"playerbots",name:"SQUID's Playerbots",description:{en:"An alternative bot system with random bots and full AI.",ru:"Альтернативная система ботов со случайными ботами и полным ИИ."},repo:"https://github.com/Zyth45/mod-playerbots",installed:true,enabled:modState["playerbots"] ?? false,switchable:true,status:"early",icon:"swords",page:null,hidden:false},{id:"client-compat",name:"Client compatibility",description:{en:"Client-server link and appearance unlocks.",ru:"Связь клиента с сервером и разблокировка внешних видов."},repo:"https://github.com/Corfirean/azerothcore-wotlk-coa",installed:true,enabled:modState["client-compat"],switchable:false,status:"release",icon:"plug",page:null,hidden:true}].map(m => ({...m, has_settings: !["tbc-content", "wotlk-content"].includes(m.id), compatibility: realm === "wildcard" ? (["companions", "playerbots"].includes(m.id) ? "unsupported" : m.id === "client-compat" ? "compatible" : "experimental") : "compatible", switchable: realm === "wildcard" && ["companions", "playerbots"].includes(m.id) ? false : m.switchable})); + if(cmd==="module_set_enabled"){ if(args.enabled && ["companions","playerbots"].includes(args.module) && modState[args.module === "companions" ? "playerbots" : "companions"]) throw {human:{code:"bot_modules_conflict",title:"",message:"",actions:[]},technical:"Only one bot system may be enabled."}; modState[args.module]=args.enabled; return null; } if(cmd==="all_settings") return [{key:"Visibility.Distance.Continents",value:"100",default:"90",doc:"Description: Visibility distance on continents. Range: 45-250 Default: 90",changed:true},{key:"Death.SicknessLevel",value:"11",default:"11",doc:"Description: Character level up to which characters get resurrection sickness. Default: 11",changed:false},{key:"Rate.Drop.Item.Epic",value:"3",default:"1",doc:"Description: Drop rates for money and items based on quality. Default: 1",changed:true},{key:"Instance.ResetTimeHour",value:"4",default:"4",doc:"Description: Hour of the day when the global instance reset occurs. Range: 0-23 Default: 4 - (04:00 AM)",changed:false},{key:"PlayerLimit",value:"1000",default:"1000",doc:"Description: Maximum number of players in the world.",changed:false}]; if(cmd==="all_settings_save") return Object.keys(args&&args.changes||{}); if(cmd==="module_settings" && args.module==="client-compat") return [{key:"CoA.UnlockLocalAppearanceCatalog",value:"1",default:"1",doc:""},{key:"CoA.UnlockAllVanity",value:"1",default:"1",doc:""},{key:"CoA.AutoCollectAppearances",value:"0",default:"1",doc:""},{key:"CoA.LearnOwnedCompanions",value:"1",default:"1",doc:""},{key:"CoA.MaxRidingFromStart",value:"1",default:"1",doc:""}]; if(cmd==="module_settings"){ const S:Record={ "npc-enchanter":[{key:"Enchanter.Enable",value:"1",default:"1",doc:"Enable the module."},{key:"Enchanter.Announce",value:"0",default:"0",doc:"Announce the module when the player logs in."},{key:"Enchanter.CostCopper",value:"0",default:"0",doc:"What one enchantment costs, in copper (10000 = 1 gold). 0 = free."},{key:"Enchanter.MessageTimer",value:"60000",default:"60000",doc:"How often the NPC emotes, in milliseconds."}], - "companions":[{key:"CoaBots.Enable",value:"1",default:"1",doc:"Master switch of the companion bots."},{key:"CoaBots.MaxPerPlayer",value:"4",default:"4",doc:"How many bots one player can have at a time."}], - "content-scaling":[{key:"CoAContentScaling.Enable",value:"1",default:"1",doc:"Scale dungeons and raids to the group."}], + "playerbots":[{"key": "AiPlayerbot.RandomBotAutologin", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.MinRandomBots", "value": "500", "default": "500", "doc": ""}, {"key": "AiPlayerbot.MaxRandomBots", "value": "500", "default": "500", "doc": ""}, {"key": "AiPlayerbot.RandomBotAccountCount", "value": "0", "default": "0", "doc": ""}, {"key": "AiPlayerbot.RandomBotMinLevel", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.RandomBotMaxLevel", "value": "60", "default": "60", "doc": ""}, {"key": "AiPlayerbot.SyncLevelWithPlayers", "value": "0", "default": "0", "doc": ""}, {"key": "AiPlayerbot.RandomBotInvitePlayer", "value": "0", "default": "0", "doc": ""}, {"key": "AiPlayerbot.RandomBotJoinLfg", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.RandomBotJoinBG", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.RandomBotAutoJoinBG", "value": "0", "default": "0", "doc": ""}, {"key": "AiPlayerbot.RandomBotTalk", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.RandomBotEmote", "value": "0", "default": "0", "doc": ""}, {"key": "AiPlayerbot.RandomBotSuggestDungeons", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.CoaClassesOnly", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.CoaSmartHeal", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.CoaSmartTank", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.CoaSpecRotations", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.CoaInterruptCoordination", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.MaxAddedBots", "value": "40", "default": "40", "doc": ""}, {"key": "AiPlayerbot.BotAutologin", "value": "0", "default": "0", "doc": ""}, {"key": "AiPlayerbot.DisabledWithoutRealPlayer", "value": "0", "default": "0", "doc": ""}, {"key": "AiPlayerbot.AddClassCommand", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.KeepAltsInGroup", "value": "0", "default": "0", "doc": ""}, {"key": "AiPlayerbot.CoaRecruitSameFaction", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.CoaLfgBots", "value": "1", "default": "1", "doc": ""}, {"key": "AiPlayerbot.RandomBotXPRate", "value": "1.0", "default": "1.0", "doc": ""}, {"key": "AiPlayerbot.FollowDistance", "value": "1.5", "default": "1.5", "doc": ""}, {"key": "AiPlayerbot.Enabled", "value": "0", "default": "0", "doc": ""}, {"key": "AiPlayerbot.RandomBotAccountPrefix", "value": "\"rndbot\"", "default": "\"rndbot\"", "doc": ""}, {"key": "AiPlayerbot.RandomBotUpdateInterval", "value": "20", "default": "20", "doc": ""}], + "companions":[{key:"CoaBots.Enable",value:"1",default:"1",doc:"Master switch of the companion bots."},{key:"CoaBots.RandomSpawn.DefaultCount",value:"10",default:"10",doc:""}], + "content-scaling":[{key:"CoAContentScaling.Enable",value:"1",default:"1",doc:"Scale dungeons and raids to the group."}, ...[{key:"CoAContentScaling.Difficulty.DamageMultiplier",value:"1.0"},{key:"CoAContentScaling.SoloAssist.Mode",value:"1"},{key:"CoAContentScaling.GroupScaling.Enable",value:"1"},{key:"CoAContentScaling.LFG.DefaultMode",value:"CurrentParty"},{key:"CoAContentScaling.LFG.DefaultChallengeSize",value:"0"},{key:"CoAContentScaling.Debug",value:"0"}].map(s=>({...s,default:s.value,doc:""}))], + "ah-bot":[...Object.keys(auctionCategories).flatMap(category => Object.keys(auctionQualities).map(quality => ({key:`AuctionHouseBot.ListProportion.Category${category}.Quality${quality}`,value:String(quality === "Normal" && !["Quest","Key","Generic","Reagent"].includes(category) ? 50 : quality === "Rare" && ["Armor","Weapon"].includes(category) ? 5 : 0),default:String(quality === "Normal" && !["Quest","Key","Generic","Reagent"].includes(category) ? 50 : quality === "Rare" && ["Armor","Weapon"].includes(category) ? 5 : 0),doc:""}))), {key:"AuctionHouseBot.Enable",value:"false",default:"false",doc:""}, ...[{key:"AuctionHouseBot.GUIDs",value:"0"},{key:"AuctionHouseBot.EnableSeller",value:"true"},{key:"AuctionHouseBot.CoA.ObtainableItemsOnly",value:"true"},{key:"AuctionHouseBot.ItemsPerCycle",value:"150"},{key:"AuctionHouseBot.MinutesBetweenSellCycle",value:"1"},{key:"AuctionHouseBot.Alliance.MinItems",value:"15000"},{key:"AuctionHouseBot.Alliance.MaxItems",value:"15000"}].map(s=>({...s,default:s.value,doc:""}))], "tbc-content":[{key:"CoATBC.Enable",value:"1",default:"1",doc:"Outland dungeons and raids."}], "wotlk-content":[{key:"CoAWotLK.Enable",value:"1",default:"1",doc:"Northrend dungeons and raids."}], }; - return S[args.module]??[]; + return (S[args.module]??[]).map(s => ({...s, value: moduleEdits[args.module]?.[s.key] ?? s.value})); } - if(cmd==="module_save_settings") return Object.keys(args.changes); + if(cmd==="module_save_settings") { moduleEdits[args.module] = {...moduleEdits[args.module], ...args.changes}; return Object.keys(args.changes); } if(cmd==="report_context") return {manager_version:"0.3.1",windows:"Windows 11 (build 26200)",install_kind:"new",server_version:"0.261001.10"}; if(cmd==="open_link"){(window as any).__lastLink=args.url;return null;} if(cmd==="export_diagnostics") return "C:\\Users\\you\\Desktop\\CoA-Diagnostics-20261001-140000.zip"; diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 3470f3b..215a7fe 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -724,6 +724,16 @@ const de: Partial> = { "realm.confirmDelete": "Realmlist „{name}“ entfernen? Der Spielclient behält die aktuelle Datei.", "nav.modules": "Module", "q.modules": "Welche Zusatzfunktionen hat der Server?", + "mod.ahbot.types": "Verkaufte Gegenstandstypen", + "mod.ahbot.typesHint": "Typen für neue Angebote auswählen. Bestehende Auktionen bleiben. Einschalten stellt die vorherigen Gewichte dieser Sitzung oder die Standardwerte wieder her.", + "mod.ahbot.qualityWeights": "Qualität und Häufigkeit", + "mod.ahbot.weightsHint": "Relative Gewichte, keine Prozente. 0 schließt eine Qualität aus; höhere Werte erhöhen die Häufigkeit. Sind alle Standardwerte 0, wird gewöhnliche Qualität gewählt. Andere Filter gelten weiterhin.", + "mod.ahbot.noTypes": "Alle Typen sind deaktiviert. Der Verkäufer hat keine Typen für neue Angebote.", + "mod.noSettings": "Keine weiteren Einstellungen. Verwende den Modulschalter oben.", + "err.bot_modules_conflict.title": "Zwei Bot-Module sind aktiviert", + "err.bot_modules_conflict.message": "Deaktiviere CoA Companions oder SQUID Playerbots unter Module vor dem Serverstart.", + "mod.ahbot.setup": "GUID eines separaten Auktionscharakters eintragen, Verkäufer und Modul aktivieren. Der Charakter muss offline sein. Der CoA-Filter nutzt Beute-, Händler- und Berufstabellen; Stufengrenzen gelten zusätzlich. Erfordert einen Serverbuild mit CoA-Auktionsfilter.", + "mod.ahbot.allSettings": "Alle Auktionseinstellungen", "mod.title": "Module", "mod.intro": "Zusatzfunktionen, die im Server eingebaut sind. Schalte die gewünschten ein oder aus, öffne ihre GitHub-Seite oder ändere ihre Einstellungen.", "mod.restart": "Änderungen werden nach einem Neustart des Weltservers wirksam.", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 4e66239..728e6c8 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -745,6 +745,16 @@ const en = { "realm.confirmDelete": "Remove the realmlist “{name}”? The game client keeps whatever file it has now.", "nav.modules": "Modules", "q.modules": "What extra features does the server have?", + "mod.ahbot.types": "Item types for sale", + "mod.ahbot.typesHint": "Choose types for new listings. Existing auctions remain. Enabling a type restores its previous weights in this session, or the configured defaults.", + "mod.ahbot.qualityWeights": "Quality and frequency", + "mod.ahbot.weightsHint": "Relative weights, not percentages. 0 excludes a quality; larger values make it more frequent. If all defaults are zero, enabling selects common quality. Other item filters still apply.", + "mod.ahbot.noTypes": "All types are disabled. The seller has no types to choose for new listings.", + "mod.noSettings": "There are no additional settings. Use the module switch above.", + "mod.ahbot.setup": "Enter a dedicated auction character GUID, enable the seller and the module. Keep that character offline. The CoA filter selects items from loot tables, vendors and professions; level restrictions also apply. Requires a server build with the CoA auction filter.", + "mod.ahbot.allSettings": "All settings", + "err.bot_modules_conflict.title": "Two bot modules are enabled", + "err.bot_modules_conflict.message": "Disable CoA Companions or SQUID Playerbots in Modules before starting the server.", "mod.title": "Modules", "mod.intro": "Extra features built into the server. Switch the ones you want on or off, open their GitHub page, or change their settings.", "mod.restart": "Changes apply after the world server restarts.", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 8cd44d3..26b97d6 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -724,6 +724,16 @@ const es: Partial> = { "realm.confirmDelete": "¿Eliminar la realmlist «{name}»? El cliente conserva el archivo actual.", "nav.modules": "Módulos", "q.modules": "¿Qué funciones adicionales tiene el servidor?", + "mod.ahbot.types": "Tipos de objetos en venta", + "mod.ahbot.typesHint": "Elige tipos para nuevos anuncios. Las subastas existentes permanecen. Al activar se restauran los pesos de esta sesión o los valores predeterminados.", + "mod.ahbot.qualityWeights": "Calidad y frecuencia", + "mod.ahbot.weightsHint": "Pesos relativos, no porcentajes. 0 excluye una calidad; valores mayores aumentan su frecuencia. Si todos los valores predeterminados son cero, se elige calidad común. Los demás filtros siguen activos.", + "mod.ahbot.noTypes": "Todos los tipos están desactivados. El vendedor no tiene tipos para nuevos anuncios.", + "mod.noSettings": "No hay ajustes adicionales. Usa el interruptor del módulo de arriba.", + "err.bot_modules_conflict.title": "Hay dos módulos de bots activos", + "err.bot_modules_conflict.message": "Desactiva CoA Companions o SQUID Playerbots en Módulos antes de iniciar el servidor.", + "mod.ahbot.setup": "Indica el GUID de un personaje dedicado a subastas y activa vendedor y módulo. El personaje debe estar desconectado. El filtro CoA usa botín, vendedores y profesiones; los límites de nivel se aplican además. Requiere un servidor con filtro de subastas CoA.", + "mod.ahbot.allSettings": "Todos los ajustes de subastas", "mod.title": "Módulos", "mod.intro": "Funciones adicionales integradas en el servidor. Activa o desactiva las que quieras, abre su página de GitHub o cambia sus ajustes.", "mod.restart": "Los cambios se aplican al reiniciar el servidor del mundo.", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 8ebab1b..6cccf22 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -724,6 +724,16 @@ const fr: Partial> = { "realm.confirmDelete": "Supprimer la realmlist « {name} » ? Le client garde le fichier actuel.", "nav.modules": "Modules", "q.modules": "Quelles fonctions supplémentaires le serveur propose-t-il ?", + "mod.ahbot.types": "Types d’objets à vendre", + "mod.ahbot.typesHint": "Choisissez les types des nouvelles annonces. Les enchères existantes restent. L’activation restaure les poids de cette session ou les valeurs par défaut.", + "mod.ahbot.qualityWeights": "Qualité et fréquence", + "mod.ahbot.weightsHint": "Poids relatifs, pas des pourcentages. 0 exclut une qualité ; un poids supérieur augmente sa fréquence. Si tous les défauts sont nuls, la qualité commune est choisie. Les autres filtres restent actifs.", + "mod.ahbot.noTypes": "Tous les types sont désactivés. Le vendeur n’a aucun type à choisir.", + "mod.noSettings": "Aucun réglage supplémentaire. Utilisez l’interrupteur du module ci-dessus.", + "err.bot_modules_conflict.title": "Deux modules de bots sont activés", + "err.bot_modules_conflict.message": "Désactivez CoA Companions ou SQUID Playerbots dans Modules avant de démarrer le serveur.", + "mod.ahbot.setup": "Indiquez le GUID d’un personnage dédié aux enchères, activez le vendeur et le module. Le personnage doit être hors ligne. Le filtre CoA utilise le butin, les vendeurs et les métiers ; les limites de niveau s’ajoutent. Nécessite un serveur avec le filtre d’enchères CoA.", + "mod.ahbot.allSettings": "Tous les réglages d’enchères", "mod.title": "Modules", "mod.intro": "Fonctions supplémentaires intégrées au serveur. Activez ou désactivez celles que vous voulez, ouvrez leur page GitHub ou modifiez leurs réglages.", "mod.restart": "Les changements s’appliquent après le redémarrage du serveur de monde.", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index ecfe7fb..3328d9d 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -743,6 +743,16 @@ const ru: Partial> = { "realm.confirmDelete": "Удалить реалмлист «{name}»? В игровом клиенте останется текущий файл.", "nav.modules": "Модули", "q.modules": "Какие дополнительные возможности есть у сервера?", + "mod.ahbot.types": "Типы предметов в продаже", + "mod.ahbot.typesHint": "Выберите типы для новых лотов. Уже выставленные лоты останутся. При включении возвращаются прежние веса этой сессии или значения по умолчанию.", + "mod.ahbot.qualityWeights": "Качество и частота", + "mod.ahbot.weightsHint": "Это относительные веса, а не проценты. 0 исключает качество; большее значение повышает частоту. Если все значения по умолчанию равны нулю, включается обычное качество. Остальные фильтры продолжают действовать.", + "mod.ahbot.noTypes": "Все типы отключены. Продавцу не из чего выбирать новые лоты.", + "mod.noSettings": "Дополнительных настроек нет. Используйте переключатель модуля выше.", + "mod.ahbot.setup": "Укажите GUID отдельного персонажа аукциониста, включите продавца и модуль. Персонаж должен быть офлайн. Фильтр CoA оставляет предметы из таблиц добычи, торговцев и профессий; ограничения уровней применяются дополнительно. Нужна сборка сервера с фильтром аукциона CoA.", + "mod.ahbot.allSettings": "Все настройки", + "err.bot_modules_conflict.title": "Включены две системы ботов", + "err.bot_modules_conflict.message": "Выключите CoA Companions или SQUID Playerbots в разделе «Модули» перед запуском сервера.", "mod.title": "Модули", "mod.intro": "Дополнительные возможности, встроенные в сервер. Включайте и выключайте нужные, открывайте их страницу на GitHub или меняйте настройки.", "mod.restart": "Изменения применятся после перезапуска игрового сервера.", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index c5d3756..54e0e80 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -724,6 +724,16 @@ const zh = { "realm.confirmDelete": "是否删除 realmlist 配置“{name}”?游戏客户端将继续保留其当前的文件内容。", "nav.modules": "扩展模块", "q.modules": "服务器支持哪些扩展模块?", + "mod.ahbot.types": "出售物品类型", + "mod.ahbot.typesHint": "选择新拍卖的物品类型,已有拍卖不受影响。启用会恢复本次会话的权重或默认值。", + "mod.ahbot.qualityWeights": "品质与频率", + "mod.ahbot.weightsHint": "相对权重而非百分比。0排除该品质,数值越大越常见。如果默认值全为0,启用时选择普通品质。其他筛选仍生效。", + "mod.ahbot.noTypes": "所有类型均已关闭,卖家没有可供新拍卖选择的类型。", + "mod.noSettings": "没有其他设置,请使用上方模块开关。", + "err.bot_modules_conflict.title": "已启用两个机器人模块", + "err.bot_modules_conflict.message": "启动服务器前,请在模块中关闭CoA Companions或SQUID Playerbots。", + "mod.ahbot.setup": "填写独立拍卖角色的GUID,并启用卖家和模块。角色必须离线。CoA筛选使用掉落、商人和专业物品表,等级限制另行生效。需要包含CoA拍卖筛选的服务器版本。", + "mod.ahbot.allSettings": "所有拍卖设置", "mod.title": "模块管理", "mod.intro": "服务器内置的可选扩展特性。可按需开关、访问其 GitHub 页面或调整独立参数。", "mod.restart": "更改将在世界服务器重启后生效。", diff --git a/src/lib/api.ts b/src/lib/api.ts index edb0586..06efb6b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -194,6 +194,7 @@ export interface ModuleView { repo: string; /** the module is part of this server build (its configuration exists) */ installed: boolean; + has_settings: boolean; enabled: boolean; /** can be turned on and off (a part the game client needs is only configured) */ switchable: boolean; diff --git a/src/lib/moduleSettings.ts b/src/lib/moduleSettings.ts new file mode 100644 index 0000000..c08fb5a --- /dev/null +++ b/src/lib/moduleSettings.ts @@ -0,0 +1,187 @@ +import bots from "../../schemas/bots.json"; +import server from "../../schemas/server.json"; +import catalog from "../../modules/catalog.json"; +import type { Locale } from "@/i18n"; +import type { ModuleSetting } from "@/lib/api"; + +export interface ModuleField { + title: string; description: string; type: string; category: string; advanced: boolean; + min?: number; max?: number; step?: number; + options?: { value: string | number | boolean; label: string }[]; +} +const locales: Locale[] = ["en", "ru", "de", "fr", "es", "zh"]; +const text = (values: string[], locale: Locale) => values[locales.indexOf(locale)] ?? values[0]; +type Entry = { key: string; type: string; title: string[]; description: string[]; min?: number; max?: number; advanced?: boolean; options?: { value: string; label: string[] }[] }; +export const moduleSettingDefinitions: Entry[] = []; +const fields = moduleSettingDefinitions; +function add(key: string, type: string, title: string[], description: string[], extra: Partial = {}) { + fields.push({ key, type, title, description, ...extra }); +} +add("AiPlayerbot.RandomBotAutologin", "bool", ["Populate the world automatically", "Автоматически населять мир", "Welt automatisch bevölkern", "Peupler automatiquement le monde", "Poblar el mundo automáticamente", "自动填充世界"], ["Log random bots into the world when the server starts.", "Вводит случайных ботов в мир при запуске сервера.", "Meldet Zufallsbots beim Serverstart in der Welt an.", "Connecte les bots aléatoires au démarrage du serveur.", "Conecta bots aleatorios al iniciar el servidor.", "服务器启动时让随机机器人登录世界。"]); +add("AiPlayerbot.MinRandomBots", "int", ["Minimum online bots", "Минимум ботов онлайн", "Mindestens aktive Bots", "Minimum de bots connectés", "Mínimo de bots conectados", "最少在线机器人"], ["Lower limit of the automatically selected online population. Must not exceed the maximum.", "Нижний предел автоматически выбираемого числа ботов онлайн. Не должен превышать максимум.", "Untergrenze der automatisch gewählten Bot-Zahl. Darf das Maximum nicht überschreiten.", "Limite basse du nombre de bots connectés. Ne doit pas dépasser le maximum.", "Límite inferior de bots conectados. No debe superar el máximo.", "自动选择的在线机器人数量下限,不得超过上限。"], { min: 0 }); +add("AiPlayerbot.MaxRandomBots", "int", ["Maximum online bots", "Максимум ботов онлайн", "Maximal aktive Bots", "Maximum de bots connectés", "Máximo de bots conectados", "最多在线机器人"], ["Upper limit of the random bot population; more bots use more server resources.", "Верхний предел числа случайных ботов онлайн. Больше ботов — выше нагрузка на сервер.", "Obergrenze der Zufallsbots; mehr Bots benötigen mehr Serverressourcen.", "Limite haute des bots aléatoires ; plus de bots utilisent plus de ressources.", "Límite superior de bots aleatorios; más bots requieren más recursos del servidor.", "随机机器人在线数量上限,数量越多越占用服务器资源。"], { min: 0 }); +add("AiPlayerbot.RandomBotAccountCount", "int", ["Random bot accounts", "Аккаунтов случайных ботов", "Zufallsbot-Konten", "Comptes de bots aléatoires", "Cuentas de bots aleatorios", "随机机器人账号数量"], ["0 calculates the required accounts automatically. Each account contains multiple bots.", "0 — рассчитывать нужное число аккаунтов автоматически. Один аккаунт содержит несколько ботов.", "0 berechnet die benötigten Konten automatisch. Ein Konto enthält mehrere Bots.", "0 calcule automatiquement les comptes nécessaires. Chaque compte contient plusieurs bots.", "0 calcula automáticamente las cuentas necesarias. Cada cuenta contiene varios bots.", "0自动计算所需账号,每个账号包含多个机器人。"], { min: 0 }); +add("AiPlayerbot.RandomBotMinLevel", "int", ["Minimum bot level", "Минимальный уровень ботов", "Minimale Bot-Stufe", "Niveau minimum des bots", "Nivel mínimo de bots", "最低机器人等级"], ["Lowest level used when generating random bots.", "Нижний уровень при создании случайных ботов.", "Niedrigste Stufe beim Erstellen von Zufallsbots.", "Niveau le plus bas lors de la génération des bots.", "Nivel más bajo al generar bots aleatorios.", "生成随机机器人时的最低等级。"], { min: 0, max: 80 }); +add("AiPlayerbot.RandomBotMaxLevel", "int", ["Maximum bot level", "Максимальный уровень ботов", "Maximale Bot-Stufe", "Niveau maximum des bots", "Nivel máximo de bots", "最高机器人等级"], ["Highest level used when generating random bots. Keep within the server level cap.", "Верхний уровень при создании случайных ботов. Не превышайте максимальный уровень сервера.", "Höchste Stufe beim Erstellen von Zufallsbots. Server-Stufenlimit beachten.", "Niveau le plus haut lors de la génération. Respectez la limite du serveur.", "Nivel máximo al generar bots. Respeta el límite del servidor.", "生成机器人的最高等级,不应超过服务器等级上限。"], { min: 0, max: 80 }); +add("AiPlayerbot.SyncLevelWithPlayers", "bool", ["Match player levels", "Подстраивать уровень под игроков", "Spielerstufen anpassen", "Adapter aux niveaux des joueurs", "Ajustar al nivel de jugadores", "匹配玩家等级"], ["Adjust random bot levels to the levels of real players.", "Подстраивает уровни случайных ботов под уровни настоящих игроков.", "Passt Zufallsbot-Stufen an echte Spieler an.", "Adapte le niveau des bots aux joueurs réels.", "Ajusta los niveles de bots a jugadores reales.", "根据真实玩家调整随机机器人等级。"]); +add("AiPlayerbot.RandomBotInvitePlayer", "bool", ["Bots invite players", "Боты приглашают игроков", "Bots laden Spieler ein", "Les bots invitent les joueurs", "Los bots invitan jugadores", "机器人邀请玩家"], ["Allow random bots to invite nearby players into groups.", "Разрешает случайным ботам приглашать игроков поблизости в группу.", "Zufallsbots dürfen nahe Spieler in Gruppen einladen.", "Permet aux bots d’inviter les joueurs proches dans un groupe.", "Permite a bots invitar a jugadores cercanos a grupos.", "允许随机机器人邀请附近玩家组队。"]); +add("AiPlayerbot.RandomBotJoinLfg", "bool", ["Join dungeon finder", "Участвовать в поиске подземелий", "Dungeonsuche nutzen", "Participer à la recherche de donjon", "Participar en buscador de mazmorras", "加入地下城查找器"], ["Allow random bots to participate in dungeon matchmaking.", "Разрешает случайным ботам участвовать в подборе группы для подземелий.", "Zufallsbots dürfen an der Dungeonsuche teilnehmen.", "Autorise les bots à participer à la recherche de donjon.", "Permite a bots participar en el buscador de mazmorras.", "允许随机机器人参与地下城匹配。"]); +add("AiPlayerbot.RandomBotJoinBG", "bool", ["Join battlegrounds", "Участвовать в полях боя", "Schlachtfeldern beitreten", "Rejoindre les champs de bataille", "Unirse a campos de batalla", "加入战场"], ["Allow random bots to enter battlegrounds.", "Разрешает случайным ботам входить на поля боя.", "Zufallsbots dürfen Schlachtfelder betreten.", "Autorise les bots à entrer sur les champs de bataille.", "Permite a bots entrar en campos de batalla.", "允许随机机器人进入战场。"]); +add("AiPlayerbot.RandomBotAutoJoinBG", "bool", ["Queue for battlegrounds automatically", "Автоматически вставать в очередь на поля боя", "Automatisch für Schlachtfelder anmelden", "Inscription automatique aux champs de bataille", "Cola automática para campos de batalla", "自动排队战场"], ["Queue random bots without waiting for a player to queue first.", "Ставит ботов в очередь, не дожидаясь первого игрока.", "Meldet Bots an, ohne auf die Anmeldung eines Spielers zu warten.", "Inscrit les bots sans attendre un premier joueur.", "Pone bots en cola sin esperar a un jugador.", "无需等待玩家先排队,即可让机器人排队。"]); +add("AiPlayerbot.RandomBotTalk", "bool", ["Bot chat", "Сообщения ботов", "Bot-Chat", "Discussion des bots", "Chat de bots", "机器人聊天"], ["Allow random bots to send chat messages.", "Разрешает случайным ботам отправлять сообщения в чат.", "Zufallsbots dürfen Chatnachrichten senden.", "Autorise les messages des bots dans le chat.", "Permite mensajes de bots en el chat.", "允许随机机器人发送聊天消息。"]); +add("AiPlayerbot.RandomBotEmote", "bool", ["Bot emotes", "Эмоции ботов", "Bot-Emotes", "Émotes des bots", "Gestos de bots", "机器人表情"], ["Allow random bots to perform emotes.", "Разрешает случайным ботам использовать эмоции.", "Zufallsbots dürfen Emotes verwenden.", "Autorise les émotes des bots.", "Permite a bots usar gestos.", "允许随机机器人使用表情动作。"]); +add("AiPlayerbot.RandomBotSuggestDungeons", "bool", ["Suggest dungeons", "Предлагать подземелья", "Dungeons vorschlagen", "Suggérer des donjons", "Sugerir mazmorras", "建议地下城"], ["Allow bots to suggest dungeons to players in chat.", "Разрешает ботам предлагать игрокам подземелья в чате.", "Bots dürfen Spielern im Chat Dungeons vorschlagen.", "Autorise les suggestions de donjons dans le chat.", "Permite sugerir mazmorras a jugadores en el chat.", "允许机器人在聊天中向玩家建议地下城。"]); +add("AiPlayerbot.CoaClassesOnly", "bool", ["Only CoA classes", "Только классы CoA", "Nur CoA-Klassen", "Classes CoA uniquement", "Solo clases CoA", "仅CoA职业"], ["Use Conquest of Azeroth classes for generated bots.", "Использует классы Conquest of Azeroth для создаваемых ботов.", "Nutzt CoA-Klassen für erstellte Bots.", "Utilise les classes CoA pour les bots générés.", "Usa clases CoA para bots generados.", "生成机器人时使用CoA职业。"]); +add("AiPlayerbot.CoaSmartHeal", "bool", ["Smart healing", "Умное лечение", "Intelligente Heilung", "Soins intelligents", "Sanación inteligente", "智能治疗"], ["Use CoA healing priorities to choose targets and spells.", "Использует приоритеты лечения CoA для выбора целей и заклинаний.", "Nutzt CoA-Heilprioritäten für Ziele und Zauber.", "Utilise les priorités de soins CoA pour les cibles et sorts.", "Usa prioridades de sanación CoA para objetivos y hechizos.", "根据CoA治疗优先级选择目标和法术。"]); +add("AiPlayerbot.CoaSmartTank", "bool", ["Smart tanking", "Умное танкование", "Intelligentes Tanken", "Tank intelligent", "Tanque inteligente", "智能坦克"], ["Use CoA tank priorities for threat and survival.", "Использует приоритеты танков CoA для угрозы и выживания.", "Nutzt CoA-Tankprioritäten für Bedrohung und Überleben.", "Utilise les priorités de tank CoA pour menace et survie.", "Usa prioridades de tanque CoA para amenaza y supervivencia.", "使用CoA坦克优先级控制仇恨和生存。"]); +add("AiPlayerbot.CoaSpecRotations", "bool", ["CoA specialization rotations", "Ротации специализаций CoA", "CoA-Spezialisierungsrotationen", "Rotations des spécialisations CoA", "Rotaciones de especialización CoA", "CoA专精循环"], ["Use authored specialization rotations alongside automatic spell selection.", "Использует готовые ротации специализаций вместе с автоматическим выбором заклинаний.", "Nutzt Spezialisierungsrotationen neben automatischer Zauberauswahl.", "Utilise les rotations de spécialisation avec la sélection automatique des sorts.", "Usa rotaciones de especialización junto a selección automática de hechizos.", "在自动选法术的基础上使用专精循环。"]); +add("AiPlayerbot.CoaInterruptCoordination", "bool", ["Coordinate interrupts", "Согласовывать прерывания", "Unterbrechungen koordinieren", "Coordonner les interruptions", "Coordinar interrupciones", "协调打断"], ["Coordinate bot interrupts so several bots do not interrupt the same cast.", "Согласовывает прерывания ботов, чтобы несколько ботов не тратили их на одно заклинание.", "Koordiniert Unterbrechungen, damit Bots nicht denselben Zauber unterbrechen.", "Coordonne les interruptions pour éviter de viser tous le même sort.", "Coordina interrupciones para evitar gastar varias en el mismo lanzamiento.", "协调打断,避免多个机器人同时打断同一个施法。"]); +add("AiPlayerbot.MaxAddedBots", "int", ["Bots controlled by one player", "Ботов под управлением игрока", "Bots pro Spieler", "Bots contrôlés par joueur", "Bots controlados por jugador", "每位玩家控制的机器人"], ["Maximum number of bots a player can control at once.", "Максимум ботов, которыми игрок может управлять одновременно.", "Maximale gleichzeitig steuerbare Bots pro Spieler.", "Nombre maximal de bots contrôlés simultanément par joueur.", "Máximo de bots controlados a la vez por jugador.", "玩家同时控制的机器人数量上限。"], { min: 0 }); +add("AiPlayerbot.BotAutologin", "bool", ["Log in player alts automatically", "Автоматически вводить альтов игрока", "Spieler-Alts automatisch anmelden", "Connecter automatiquement les personnages secondaires", "Conectar personajes alternativos automáticamente", "自动登录玩家小号"], ["Log in other characters on the player’s account as bots when the player logs in.", "При входе игрока вводит других персонажей его аккаунта как ботов.", "Meldet andere Charaktere des Spielerkontos beim Login als Bots an.", "Connecte les autres personnages du compte comme bots à la connexion.", "Conecta otros personajes de la cuenta como bots al entrar el jugador.", "玩家登录时,将同账号其他角色作为机器人登录。"]); +add("AiPlayerbot.DisabledWithoutRealPlayer", "bool", ["Only populate while players are online", "Вводить ботов только при наличии игроков", "Bots nur bei aktiven Spielern", "Bots seulement avec des joueurs connectés", "Bots solo con jugadores conectados", "仅有玩家在线时填充机器人"], ["Log random bots out when no real players remain online.", "Выводит случайных ботов, когда на сервере нет настоящих игроков.", "Meldet Zufallsbots ab, wenn keine echten Spieler online sind.", "Déconnecte les bots quand aucun joueur réel ne reste connecté.", "Desconecta bots cuando no quedan jugadores reales.", "没有真实玩家在线时退出随机机器人。"]); +add("AiPlayerbot.AddClassCommand", "bool", ["Players can create bots by class", "Игроки могут создавать ботов по классу", "Spieler können Klassenbots erstellen", "Les joueurs peuvent créer des bots par classe", "Jugadores pueden crear bots por clase", "玩家可按职业创建机器人"], ["Allow the addclass command for players; disabled restricts it to game masters.", "Разрешает игрокам команду addclass. При выключении она доступна только ГМ.", "Erlaubt addclass für Spieler; deaktiviert nur für Spielleiter.", "Autorise addclass aux joueurs ; sinon réservé aux maîtres de jeu.", "Permite addclass a jugadores; desactivado solo para maestros de juego.", "允许玩家使用addclass命令,关闭后仅GM可用。"]); +add("AiPlayerbot.KeepAltsInGroup", "bool", ["Keep alts in the group", "Оставлять альтов в группе", "Alts in der Gruppe behalten", "Garder les personnages secondaires en groupe", "Mantener personajes alternativos en el grupo", "保留小号在队伍中"], ["Keep alt bots in the group after their owner leaves.", "Оставляет альтов-ботов в группе после выхода владельца.", "Behält Alt-Bots in der Gruppe, wenn ihr Besitzer geht.", "Garde les bots secondaires après le départ du propriétaire.", "Mantiene bots alternativos cuando sale su dueño.", "主人离队后,让小号机器人留在队伍中。"]); +add("AiPlayerbot.CoaRecruitSameFaction", "bool", ["Recruit bots of your faction", "Нанимать ботов своей фракции", "Bots der eigenen Fraktion anwerben", "Recruter les bots de votre faction", "Reclutar bots de tu facción", "招募同阵营机器人"], ["Restrict CoA role recruitment to the player’s faction.", "Ограничивает найм по роли CoA фракцией игрока.", "Beschränkt die CoA-Rollenanwerbung auf die Spielerfraktion.", "Limite le recrutement CoA à la faction du joueur.", "Limita el reclutamiento CoA a la facción del jugador.", "将CoA按职责招募限制为玩家同阵营。"]); +add("AiPlayerbot.CoaLfgBots", "bool", ["CoA bots fill dungeon groups", "Боты CoA заполняют группы подземелий", "CoA-Bots füllen Dungeongruppen", "Les bots CoA complètent les groupes de donjon", "Bots CoA completan grupos de mazmorras", "CoA机器人补齐地下城队伍"], ["Allow CoA bots to fill missing roles in dungeon finder groups.", "Разрешает ботам CoA занимать недостающие роли в группах поиска подземелий.", "Erlaubt CoA-Bots fehlende Rollen in Dungeonsuche-Gruppen zu füllen.", "Autorise les bots CoA à compléter les rôles manquants.", "Permite a bots CoA cubrir roles faltantes en grupos del buscador.", "允许CoA机器人补齐地下城查找器队伍的缺失职责。"]); +add("AiPlayerbot.RandomBotXPRate", "float", ["Bot experience multiplier", "Множитель опыта ботов", "Bot-Erfahrungsmultiplikator", "Multiplicateur d’expérience des bots", "Multiplicador de experiencia de bots", "机器人经验倍率"], ["1 uses the normal experience rate for random bots.", "1 — обычная скорость получения опыта случайными ботами.", "1 nutzt die normale Erfahrungsrate für Zufallsbots.", "1 conserve le gain d’expérience normal des bots aléatoires.", "1 usa la experiencia normal de bots aleatorios.", "1使用随机机器人的正常经验获取倍率。"], { min: 0 }); +add("AiPlayerbot.FollowDistance", "float", ["Follow distance", "Дистанция следования", "Folgeabstand", "Distance de suivi", "Distancia de seguimiento", "跟随距离"], ["Distance bots maintain while following their owner.", "Расстояние до владельца при следовании.", "Abstand zum Besitzer beim Folgen.", "Distance maintenue en suivant le propriétaire.", "Distancia mantenida al seguir al dueño.", "跟随主人时保持的距离。"], { min: 0 }); +const scaling = "CoAContentScaling."; +add(scaling + "Difficulty.DamageMultiplier", "range", ["Enemy damage", "Урон противников", "Gegnerschaden", "Dégâts ennemis", "Daño enemigo", "敌人伤害"], ["Dungeon and raid damage after group scaling and solo assistance. 100% keeps the normal balance; lower is easier. Applies to everyone on this server. Does not change health, loot or instant-kill mechanics.", "Урон в подземельях и рейдах после скейлинга группы и соло-помощи. 100% — обычный баланс, ниже — легче. Действует для всех игроков сервера. Не меняет здоровье, добычу и механики мгновенной смерти.", "Schaden in Dungeons und Schlachtzügen nach Gruppenanpassung und Solohilfe. 100% ist normal, weniger ist leichter. Gilt für alle Spieler. Ändert weder Gesundheit noch Beute oder Soforttötungen.", "Dégâts en donjon et raid après adaptation au groupe et aide solo. 100% conserve l’équilibrage normal ; moins est plus facile. Pour tous les joueurs. Ne change ni la vie, ni le butin, ni les morts instantanées.", "Daño en mazmorras y bandas tras el ajuste del grupo y la ayuda individual. 100% mantiene el equilibrio normal; menos es más fácil. Para todos los jugadores. No cambia salud, botín ni muerte instantánea.", "队伍缩放和单人辅助后的地下城与团队副本伤害。100%为正常,降低后更容易。对全服玩家生效,不改变生命值、掉落或即死机制。"], { min: 0.25, max: 2 }); +add(scaling + "SoloAssist.Mode", "enum", ["Solo assistance", "Помощь в соло", "Solohilfe", "Aide solo", "Ayuda individual", "单人辅助"], ["Additional damage reduction when the scaling target is one player.", "Дополнительное снижение урона, когда расчёт сложности рассчитан на одного игрока.", "Zusätzliche Schadensreduktion bei einem Skalierungsziel von einem Spieler.", "Réduction supplémentaire quand la difficulté est calculée pour un joueur.", "Reducción adicional cuando la dificultad se calcula para un jugador.", "难度目标为一名玩家时额外减伤。"], { options: [ + { value: "None", label: ["None", "Без помощи", "Keine", "Aucune", "Ninguna", "无"] }, + { value: "Light", label: ["Light (−15%)", "Лёгкая (−15%)", "Leicht (−15%)", "Légère (−15%)", "Ligera (−15%)", "轻度(−15%)"] }, + { value: "Full", label: ["Full (−30%)", "Полная (−30%)", "Voll (−30%)", "Complète (−30%)", "Completa (−30%)", "完整(−30%)"] }, +] }); +add(scaling + "GroupScaling.Enable", "bool", ["Scale to group size", "Подстраивать под размер группы", "An Gruppengröße anpassen", "Adapter à la taille du groupe", "Ajustar al tamaño del grupo", "按队伍人数缩放"], ["Adjust enemy health and damage to the party size. Required for damage difficulty and solo assistance.", "Подстраивает здоровье и урон врагов под группу. Необходимо для регулировки урона и соло-помощи.", "Passt Gesundheit und Schaden an die Gruppe an. Für Schadensregler und Solohilfe erforderlich.", "Adapte la vie et les dégâts au groupe. Requis pour le réglage des dégâts et l’aide solo.", "Ajusta salud y daño al grupo. Necesario para el control de daño y la ayuda individual.", "按队伍人数调整敌人生命和伤害。伤害调节和单人辅助需要启用此项。"]); +add(scaling + "GroupScaling.AllowSoloRaids", "bool", ["Enter raids solo", "Входить в рейды одному", "Schlachtzüge solo betreten", "Entrer seul en raid", "Entrar solo en bandas", "单人进入团队副本"], ["Allow raid entry without a raid group.", "Разрешает вход без рейдовой группы.", "Erlaubt den Eintritt ohne Schlachtzugsgruppe.", "Autorise l’entrée sans groupe de raid.", "Permite entrar sin grupo de banda.", "允许无需团队即可进入团队副本。"]); +add(scaling + "AdaptiveMechanics.Enable", "bool", ["Adapt encounter mechanics", "Адаптировать механики боёв", "Kampfmechaniken anpassen", "Adapter les mécaniques", "Adaptar mecánicas", "调整战斗机制"], ["Adapt supported encounters for smaller groups.", "Адаптирует поддерживаемые бои для малых групп.", "Passt unterstützte Kämpfe für kleine Gruppen an.", "Adapte les combats pris en charge aux petits groupes.", "Adapta los encuentros compatibles a grupos pequeños.", "为小队调整受支持的战斗。"]); +add(scaling + "Rewards.ScaleLootCount", "bool", ["Scale loot quantity", "Подстраивать количество добычи", "Beutemenge anpassen", "Adapter la quantité de butin", "Ajustar cantidad de botín", "缩放掉落数量"], ["Adjust drops to group size, keeping at least one drop.", "Подстраивает добычу под группу, сохраняя хотя бы один предмет.", "Passt Beute an die Gruppe an, mindestens ein Gegenstand bleibt.", "Adapte le butin au groupe, avec au moins un objet.", "Ajusta el botín al grupo, con al menos un objeto.", "按队伍人数调整掉落,至少保留一件。"]); +add(scaling + "ScaleItems", "bool", ["Scale equipment", "Масштабировать снаряжение", "Ausrüstung skalieren", "Adapter l’équipement", "Ajustar equipo", "缩放装备"], ["Adjust equipment stats to the selected progression level bands.", "Подстраивает характеристики снаряжения под уровни прогрессии.", "Passt Ausrüstungswerte an die Fortschrittsstufen an.", "Adapte les statistiques aux niveaux de progression.", "Ajusta estadísticas a los niveles de progresión.", "按所选进度等级范围调整装备属性。"]); +add(scaling + "LFG.DefaultMode", "enum", ["Dungeon Finder group", "Группа для поиска подземелий", "Dungeonbrowser-Gruppe", "Groupe de recherche de donjons", "Grupo del buscador", "地下城查找器组队"], ["Default queue mode. Players can override it with .lfgmode.", "Режим очереди по умолчанию. Игрок может изменить его командой .lfgmode.", "Standard-Warteschlange. Spieler können sie mit .lfgmode ändern.", "Mode de file par défaut. Modifiable avec .lfgmode.", "Cola predeterminada. Se puede cambiar con .lfgmode.", "默认排队模式,玩家可通过 .lfgmode 更改。"], { options: [ + { value: "Matchmaking", label: ["Find players", "Искать игроков", "Spieler suchen", "Chercher des joueurs", "Buscar jugadores", "寻找玩家"] }, + { value: "BotFill", label: ["Fill with bots", "Дополнить ботами", "Mit Bots auffüllen", "Compléter avec des bots", "Completar con bots", "机器人补位"] }, + { value: "CurrentParty", label: ["Current party / solo", "Текущая группа / соло", "Aktuelle Gruppe / solo", "Groupe actuel / solo", "Grupo actual / solo", "当前队伍 / 单人"] }, +] }); +add(scaling + "LFG.DefaultChallengeSize", "int", ["Challenge group size", "Размер группы для расчёта сложности", "Gruppengröße für die Schwierigkeit", "Taille du groupe pour la difficulté", "Tamaño del grupo para dificultad", "难度目标人数"], ["0 follows actual party size. 1–40 simulates that many players. Players can override with .lfgchallenge.", "0 — реальный размер группы. 1–40 — сложность как для указанного числа игроков. Игрок может изменить через .lfgchallenge.", "0 nutzt die tatsächliche Gruppe. 1–40 simuliert diese Spielerzahl. Mit .lfgchallenge änderbar.", "0 suit le groupe réel. 1–40 simule ce nombre de joueurs. Modifiable avec .lfgchallenge.", "0 usa el grupo real. 1–40 simula ese número de jugadores. Modificable con .lfgchallenge.", "0按实际人数,1–40模拟指定人数。玩家可通过 .lfgchallenge 更改。"], { min: 0, max: 40 }); +add(scaling + "GroupScaling.LockOnEncounterStart", "bool", ["Lock scaling during combat", "Фиксировать скейлинг на время боя", "Skalierung im Kampf sperren", "Figer l’adaptation en combat", "Fijar ajuste durante combate", "战斗期间锁定缩放"], ["Joining or leaving during a boss fight does not change its scaling.", "Вход и выход игроков во время боя с боссом не меняют скейлинг.", "Beitreten oder Verlassen während eines Bosskampfs ändert die Skalierung nicht.", "Les arrivées et départs pendant un boss ne changent pas l’adaptation.", "Entrar o salir durante un jefe no cambia el ajuste.", "首领战期间玩家进出不会改变缩放。"], { advanced: true }); +add(scaling + "Progression.Mode", "enum", ["Progression boundaries", "Границы прогрессии", "Fortschrittsgrenzen", "Limites de progression", "Límites de progresión", "进度边界"], ["Automatic boundaries follow the level cap and enabled expansions. Custom uses the levels below.", "Автоматические границы зависят от максимального уровня и включённых дополнений. Ручные используют уровни ниже.", "Automatisch folgt der Maximalstufe und Erweiterungen. Benutzerdefiniert nutzt die folgenden Stufen.", "Automatique suit le niveau maximal et les extensions. Manuel utilise les niveaux ci-dessous.", "Automático sigue el nivel máximo y las expansiones. Manual usa los niveles siguientes.", "自动边界按等级上限和启用的资料片计算,自定义使用以下等级。"], { advanced: true, options: [ + { value: "Auto", label: ["Automatic", "Автоматически", "Automatisch", "Automatique", "Automático", "自动"] }, + { value: "Custom", label: ["Custom", "Вручную", "Benutzerdefiniert", "Manuel", "Manual", "自定义"] }, +] }); +for (const era of ["Classic", "Tbc"]) add(scaling + `Progression.${era}End`, "int", [`${era} last level`, `Последний уровень ${era}`, `${era}-Endstufe`, `Dernier niveau ${era}`, `Último nivel ${era}`, `${era} 最高等级`], ["Used in custom progression. 0 chooses automatically.", "Для ручной прогрессии. 0 — автоматически.", "Für benutzerdefinierten Fortschritt. 0 wählt automatisch.", "Pour la progression manuelle. 0 choisit automatiquement.", "Para progresión manual. 0 elige automáticamente.", "用于自定义进度,0为自动。"], { min: 0, max: 80, advanced: true }); +add(scaling + "Debug", "bool", ["Detailed scaling logs", "Подробные логи скейлинга", "Ausführliche Skalierungsprotokolle", "Journaux détaillés", "Registros detallados", "详细缩放日志"], ["Log scaling calculations for troubleshooting.", "Записывает расчёты скейлинга для диагностики.", "Protokolliert Berechnungen zur Fehleranalyse.", "Enregistre les calculs pour le diagnostic.", "Registra cálculos para diagnóstico.", "记录缩放计算以排查问题。"], { advanced: true }); + +const enchantDescription = ["Tell players about the enchanter NPC when they log in.", "Сообщает игрокам о НПС-зачарователе при входе в игру.", "Informiert Spieler beim Einloggen über den Verzauberer-NPC.", "Informe les joueurs du PNJ enchanteur à la connexion.", "Informa a los jugadores del PNJ encantador al iniciar sesión.", "玩家登录时提示附魔NPC。"]; +add("Enchanter.CostCopper", "int", ["Price per enchantment (copper)", "Цена зачарования (медь)", "Preis pro Verzauberung (Kupfer)", "Prix par enchantement (cuivre)", "Precio por encantamiento (cobre)", "每次附魔价格(铜币)"], ["0 is free. 10,000 copper = 1 gold.", "0 — бесплатно. 10 000 меди = 1 золото.", "0 ist kostenlos. 10.000 Kupfer = 1 Gold.", "0 est gratuit. 10 000 cuivre = 1 or.", "0 es gratis. 10 000 cobre = 1 oro.", "0为免费。10000铜币=1金币。"], { min: 0 }); +add("Enchanter.Announce", "bool", ["Announce on login", "Объявлять при входе", "Beim Einloggen ankündigen", "Annonce à la connexion", "Anunciar al iniciar sesión", "登录时公告"], enchantDescription); +for (const [key, title, desc] of [ + ["MessageTimer", ["Speech interval (milliseconds)", "Интервал реплик (миллисекунды)", "Sprechintervall (Millisekunden)", "Intervalle de parole (millisecondes)", "Intervalo de habla (milisegundos)", "发言间隔(毫秒)"], ["0 disables speech; 60,000 milliseconds = 1 minute.", "0 отключает реплики; 60 000 мс = 1 минута.", "0 deaktiviert Sprache; 60.000 ms = 1 Minute.", "0 désactive la parole ; 60 000 ms = 1 minute.", "0 desactiva el habla; 60 000 ms = 1 minuto.", "0关闭发言;60000毫秒=1分钟。"]], + ["NumPhrases", ["Number of phrases", "Количество реплик", "Anzahl der Sätze", "Nombre de phrases", "Número de frases", "台词数量"], ["Number of configured phrases the NPC can choose from. 0 disables phrases.", "Количество настроенных реплик НПС. 0 отключает реплики.", "Anzahl verfügbarer Sätze. 0 deaktiviert sie.", "Nombre de phrases disponibles. 0 les désactive.", "Número de frases disponibles. 0 las desactiva.", "NPC可选台词数量,0关闭。"]], + ["EmoteSpell", ["Attention spell ID", "ID заклинания для привлечения внимания", "Aufmerksamkeitszauber-ID", "ID du sort d’attention", "ID del hechizo de atención", "吸引注意的法术ID"], ["Spell cast by the NPC. 0 disables it.", "Заклинание НПС. 0 отключает его.", "Zauber des NPCs. 0 deaktiviert ihn.", "Sort du PNJ. 0 le désactive.", "Hechizo del PNJ. 0 lo desactiva.", "NPC施放的法术,0关闭。"]], + ["EmoteCommand", ["Speech gesture ID", "ID жеста при разговоре", "Gesten-ID beim Sprechen", "ID du geste de parole", "ID del gesto al hablar", "发言动作ID"], ["0 disables gestures; 3 is a wave.", "0 отключает жесты; 3 — помахать рукой.", "0 deaktiviert Gesten; 3 ist Winken.", "0 désactive les gestes ; 3 est un salut.", "0 desactiva gestos; 3 es saludar.", "0关闭动作;3为挥手。"]], +] as [string, string[], string[]][]) add(`Enchanter.${key}`, "int", title, desc, { min: 0, advanced: true }); +for (let n = 1; n <= 3; n++) add(`EC.P${n}`, "string", [`NPC phrase ${n}`, `Реплика НПС ${n}`, `NPC-Satz ${n}`, `Phrase du PNJ ${n}`, `Frase del PNJ ${n}`, `NPC台词 ${n}`], ["Text spoken by the NPC. Keep the enclosing quotes.", "Текст реплики НПС. Сохраните кавычки вокруг текста.", "Gesprochener Text. Anführungszeichen beibehalten.", "Texte prononcé. Conservez les guillemets.", "Texto hablado. Conserva las comillas.", "NPC发言内容,请保留外层引号。"], { advanced: true }); + +const ah = "AuctionHouseBot."; +add(ah + "GUIDs", "string", ["Auction characters", "Персонажи аукциона", "Auktionscharaktere", "Personnages d’enchères", "Personajes de subasta", "拍卖角色"], ["Character GUIDs separated by commas, without spaces. Use dedicated offline characters, not companion bots. 0 selects nobody.", "GUID персонажей через запятую без пробелов. Используйте отдельных офлайн-персонажей, не ботов-компаньонов. 0 — никто.", "Charakter-GUIDs durch Kommas, ohne Leerzeichen. Separate Offline-Charaktere verwenden, keine Begleiter-Bots. 0 wählt niemanden.", "GUID séparés par des virgules sans espaces. Utilisez des personnages dédiés hors ligne, pas des bots compagnons. 0 ne sélectionne personne.", "GUID separados por comas sin espacios. Usa personajes dedicados desconectados, no bots acompañantes. 0 no selecciona a nadie.", "角色GUID以逗号分隔,不加空格。使用专用离线角色,勿用伙伴机器人。0不选择任何角色。"]); +add(ah + "EnableSeller", "bool", ["Post auctions", "Выставлять лоты", "Auktionen einstellen", "Publier des enchères", "Publicar subastas", "发布拍卖"], ["Let the auction characters sell items. The module switch and valid character GUIDs are also required.", "Разрешает персонажам продавать предметы. Нужны включённый модуль и действительные GUID персонажей.", "Erlaubt Verkäufe. Modulschalter und gültige Charakter-GUIDs sind ebenfalls erforderlich.", "Autorise les ventes. Le module doit être activé et les GUID valides.", "Permite vender. También requiere el módulo activado y GUID válidos.", "允许拍卖角色出售物品,还需启用模块并填写有效角色GUID。"]); +add(ah + "CoA.ObtainableItemsOnly", "bool", ["Only obtainable items", "Только доступные в игре предметы", "Nur erhältliche Gegenstände", "Objets accessibles uniquement", "Solo objetos obtenibles", "仅可获取物品"], ["List items from world loot, vendors and professions. Other item filters still apply.", "Выставляет предметы из добычи, торговцев и профессий. Остальные фильтры продолжают действовать.", "Listet Gegenstände aus Beute, Händlern und Berufen. Weitere Filter gelten weiterhin.", "Publie les objets de butin, vendeurs et métiers. Les autres filtres s’appliquent.", "Publica objetos de botín, vendedores y profesiones. Los demás filtros siguen activos.", "发布来自掉落、商人和专业的物品,其他筛选仍生效。"]); +add(ah + "ItemsPerCycle", "int", ["Listings per cycle", "Лотов за цикл", "Angebote pro Durchlauf", "Annonces par cycle", "Anuncios por ciclo", "每轮上架数量"], ["Maximum items posted during one auction update.", "Максимум предметов за одно обновление аукциона.", "Maximale Angebote pro Auktionsaktualisierung.", "Nombre maximal d’objets par mise à jour.", "Máximo de objetos por actualización.", "每轮拍卖更新最多上架的物品数量。"], { min: 0 }); +for (const [kind, titles] of [ + ["Buy", ["Buying interval (minutes)", "Интервал покупки (минуты)", "Kaufintervall (Minuten)", "Intervalle d’achat (minutes)", "Intervalo de compra (minutos)", "购买间隔(分钟)"]], + ["Sell", ["Selling interval (minutes)", "Интервал продажи (минуты)", "Verkaufsintervall (Minuten)", "Intervalle de vente (minutes)", "Intervalo de venta (minutos)", "出售间隔(分钟)"]], +] as [string, string[]][]) add(ah + `MinutesBetween${kind}Cycle`, "string", titles, ["A number for a fixed interval, or min:max for a random interval (e.g. 3:15).", "Число для фиксированного интервала или мин:макс для случайного (например, 3:15).", "Zahl für ein festes Intervall oder min:max für ein zufälliges (z. B. 3:15).", "Un nombre pour un délai fixe, ou min:max pour un délai aléatoire (ex. 3:15).", "Número para intervalo fijo o mín:máx para uno aleatorio (p. ej. 3:15).", "固定间隔填数字,随机间隔填最小:最大,如3:15。"]); +for (const [faction, names] of [ + ["Alliance", ["Alliance", "Альянс", "Allianz", "Alliance", "Alianza", "联盟"]], + ["Horde", ["Horde", "Орда", "Horde", "Horde", "Horda", "部落"]], + ["Neutral", ["Neutral", "Нейтральный аукцион", "Neutral", "Neutre", "Neutral", "中立"]], +] as [string, string[]][]) for (const bound of ["Min", "Max"]) { + const maximum = bound === "Max"; + add(ah + `${faction}.${bound}Items`, "int", [ + `${names[0]}: ${maximum ? "maximum" : "minimum"} listings`, `${names[1]}: ${maximum ? "максимум" : "минимум"} лотов`, `${names[2]}: ${maximum ? "maximale" : "minimale"} Angebote`, `${names[3]} : annonces ${maximum ? "maximales" : "minimales"}`, `${names[4]}: anuncios ${maximum ? "máximos" : "mínimos"}`, `${names[5]}:${maximum ? "最多" : "最少"}上架数量`, + ], ["Target listing count for this auction house. Keep the minimum no higher than the maximum.", "Целевое число лотов этого аукциона. Минимум не должен превышать максимум.", "Zielanzahl für dieses Auktionshaus. Minimum nicht höher als Maximum einstellen.", "Nombre cible pour cet hôtel des ventes. Le minimum ne doit pas dépasser le maximum.", "Cantidad objetivo para esta casa de subastas. El mínimo no debe superar el máximo.", "此拍卖行的目标上架数量,最小值不得超过最大值。"], { min: 0 }); +} +add(ah + "Buyer.Enabled", "bool", ["Buy player auctions", "Покупать лоты игроков", "Spielerauktionen kaufen", "Acheter les enchères des joueurs", "Comprar subastas de jugadores", "购买玩家拍卖"], ["Enable the auction buyer. It spends the auction characters’ money.", "Включает покупателя. Он тратит деньги персонажей аукциона.", "Aktiviert Käufe mit dem Geld der Auktionscharaktere.", "Active les achats avec l’argent des personnages d’enchères.", "Activa las compras con el dinero de los personajes de subasta.", "启用拍卖买家,会花费拍卖角色的金币。"]); +add(ah + "Buyer.BuyCandidatesPerBuyCycle", "int", ["Purchase candidates per cycle", "Кандидатов на покупку за цикл", "Kaufkandidaten pro Durchlauf", "Achats potentiels par cycle", "Candidatos de compra por ciclo", "每轮购买候选数量"], ["How many auctions the buyer considers per cycle.", "Сколько лотов покупатель рассматривает за цикл.", "Anzahl geprüfter Auktionen pro Durchlauf.", "Nombre d’enchères examinées par cycle.", "Subastas examinadas por ciclo.", "买家每轮考虑的拍卖数量。"], { min: 0 }); +add(ah + "Buyer.AcceptablePriceModifier", "float", ["Buyer price multiplier", "Множитель цены покупателя", "Kaufpreisfaktor", "Multiplicateur du prix d’achat", "Multiplicador del precio de compra", "买家价格倍率"], ["1 uses the calculated price. Higher values allow more expensive purchases.", "1 — расчётная цена. Большие значения допускают более дорогие покупки.", "1 nutzt den berechneten Preis. Höhere Werte erlauben teurere Käufe.", "1 utilise le prix calculé. Une valeur supérieure autorise des achats plus chers.", "1 usa el precio calculado. Valores mayores permiten compras más caras.", "1使用计算价格,增大后允许更高价购买。"], { min: 0 }); +add(ah + "MaxBuyoutPriceInCopper", "int", ["Maximum buyout (copper)", "Максимальная цена выкупа (медь)", "Maximaler Sofortkauf (Kupfer)", "Rachat maximal (cuivre)", "Compra máxima (cobre)", "最高一口价(铜币)"], ["Upper price limit. 10,000 copper = 1 gold.", "Верхний предел цены. 10 000 меди = 1 золото.", "Preisobergrenze. 10.000 Kupfer = 1 Gold.", "Prix maximal. 10 000 cuivre = 1 or.", "Precio máximo. 10 000 cobre = 1 oro.", "价格上限,10000铜币=1金币。"], { min: 0 }); +for (const [key, titles] of [ + ["Buyer.AlwaysBidMaxCalculatedPrice", ["Bid the calculated maximum", "Ставить расчётный максимум", "Berechnetes Maximum bieten", "Enchérir au maximum calculé", "Pujar el máximo calculado", "按计算最高价出价"]], + ["Buyer.PreventOverpayingForVendorItems", ["Avoid overpaying for vendor items", "Не переплачивать за товары торговцев", "Händlerwaren nicht überbezahlen", "Éviter de surpayer les objets de vendeurs", "Evitar sobrepagar objetos de vendedores", "避免高价购买商人物品"]], + ["Buyer.BidAgainstPlayers", ["Compete with player bids", "Конкурировать со ставками игроков", "Mit Spielergeboten konkurrieren", "Concurrencer les enchères des joueurs", "Competir con pujas de jugadores", "与玩家竞价"]], +] as [string, string[]][]) add(ah + key, "bool", titles, ["Controls the auction buyer’s bidding behaviour.", "Настраивает поведение покупателя при торгах.", "Steuert das Bietverhalten des Käufers.", "Règle le comportement d’enchères de l’acheteur.", "Controla las pujas del comprador.", "控制拍卖买家的出价行为。"]); +for (const [prefix, labels, bounds, titles] of [ + ["ListedItemLevelRestrict", ["Filter by item level", "Фильтр по уровню предметов", "Nach Gegenstandsstufe filtern", "Filtrer par niveau d’objet", "Filtrar por nivel de objeto", "按物品等级筛选"], ["MinItemLevel", "MaxItemLevel"], ["Item level", "Уровень предметов", "Gegenstandsstufe", "Niveau d’objet", "Nivel de objeto", "物品等级"]], + ["EquipItemUseOrEquipLevelRestrict", ["Filter by required player level", "Фильтр по требуемому уровню игрока", "Nach benötigter Spielerstufe filtern", "Filtrer par niveau requis", "Filtrar por nivel requerido", "按角色需求等级筛选"], ["MinLevel", "MaxLevel"], ["Required level", "Требуемый уровень", "Benötigte Stufe", "Niveau requis", "Nivel requerido", "需求等级"]], +] as [string, string[], string[], string[]][]) { + const desc = ["Only list items within these limits when this filter is enabled.", "При включённом фильтре выставляет только предметы в этих пределах.", "Bei aktivem Filter nur Gegenstände innerhalb dieser Grenzen einstellen.", "Lorsque le filtre est actif, publier uniquement les objets dans ces limites.", "Con filtro activo, publicar solo objetos dentro de estos límites.", "启用筛选后仅发布范围内的物品。"]; + add(ah + prefix + ".Enabled", "bool", labels, desc); + bounds.forEach((bound, i) => add(ah + prefix + "." + bound, "int", [`${titles[0]}: ${i ? "maximum" : "minimum"}`, `${titles[1]}: ${i ? "максимум" : "минимум"}`, `${titles[2]}: ${i ? "Maximum" : "Minimum"}`, `${titles[3]} : ${i ? "maximum" : "minimum"}`, `${titles[4]}: ${i ? "máximo" : "mínimo"}`, `${titles[5]}:${i ? "最大" : "最小"}`], desc, { min: 0 })); +} +export function moduleEnableKey(id: string) { return catalog.modules.find(m => m.id === id)?.enable_key; } +export const auctionCategories: Record = { + Consumable: ["Consumables", "Расходники", "Verbrauchsgüter", "Consommables", "Consumibles", "消耗品"], + Container: ["Bags", "Сумки", "Taschen", "Sacs", "Bolsas", "背包"], + Weapon: ["Weapons", "Оружие", "Waffen", "Armes", "Armas", "武器"], + Gem: ["Gems", "Самоцветы", "Edelsteine", "Gemmes", "Gemas", "宝石"], + Armor: ["Armor", "Броня", "Rüstung", "Armures", "Armaduras", "护甲"], + Reagent: ["Spell reagents", "Реагенты заклинаний", "Zauberreagenzien", "Composants de sorts", "Componentes de hechizos", "施法材料"], + Projectile: ["Ammunition", "Боеприпасы", "Munition", "Munitions", "Munición", "弹药"], + TradeGood: ["Crafting materials", "Материалы для профессий", "Handwerksmaterialien", "Matériaux d’artisanat", "Materiales de profesión", "专业材料"], + Generic: ["Generic items", "Общие предметы", "Allgemeine Gegenstände", "Objets génériques", "Objetos genéricos", "通用物品"], + Recipe: ["Recipes", "Рецепты", "Rezepte", "Recettes", "Recetas", "配方"], + Quiver: ["Quivers and ammo pouches", "Колчаны и подсумки", "Köcher und Munitionstaschen", "Carquois et gibernes", "Carcajes y bolsas de munición", "箭袋与弹药包"], + Quest: ["Quest items", "Предметы заданий", "Questgegenstände", "Objets de quête", "Objetos de misión", "任务物品"], + Key: ["Keys", "Ключи", "Schlüssel", "Clés", "Llaves", "钥匙"], + Misc: ["Miscellaneous", "Разное", "Verschiedenes", "Divers", "Varios", "杂项"], + Glyph: ["Glyphs", "Символы", "Glyphen", "Glyphes", "Glifos", "雕文"], +}; +export const auctionQualities: Record = { + Poor: ["Poor", "Низкое", "Schlecht", "Médiocre", "Pobre", "粗糙"], + Normal: ["Common", "Обычное", "Gewöhnlich", "Commun", "Común", "普通"], + Uncommon: ["Uncommon", "Необычное", "Ungewöhnlich", "Inhabituel", "Poco común", "优秀"], + Rare: ["Rare", "Редкое", "Selten", "Rare", "Raro", "精良"], + Epic: ["Epic", "Эпическое", "Episch", "Épique", "Épico", "史诗"], + Legendary: ["Legendary", "Легендарное", "Legendär", "Légendaire", "Legendario", "传说"], + Artifact: ["Artifact", "Артефакт", "Artefakt", "Artefact", "Artefacto", "神器"], + Heirloom: ["Heirloom", "Наследуемое", "Erbstück", "Héritage", "Reliquia", "传家宝"], +}; +export const auctionCategoryLabel = (category: string, locale: Locale) => text(auctionCategories[category], locale); +export const auctionQualityLabel = (quality: string, locale: Locale) => text(auctionQualities[quality], locale); +export const auctionWeightKey = (key: string) => /^AuctionHouseBot\.ListProportion\.Category([A-Za-z]+)\.Quality([A-Za-z]+)$/.exec(key); +export function auctionCategorySettings(items: ModuleSetting[], category: string) { + return items.filter(item => auctionWeightKey(item.key)?.[1] === category); +} +/** Turn a type off across all qualities; on restores weights or selects one supported common quality. */ +export function auctionTypeChanges(items: ModuleSetting[], on: boolean, remembered: Record = {}): Record { + if (!on) return Object.fromEntries(items.map(item => [item.key, "0"])); + const restored = Object.fromEntries(items.map(item => [item.key, remembered[item.key] ?? item.default ?? "0"])); + if (!Object.values(restored).some(value => Number(value) > 0)) { + const fallback = items.find(item => auctionWeightKey(item.key)?.[2] === "Normal") ?? items[0]; + if (fallback) restored[fallback.key] = "1"; + } + return restored; +} +for (const [category, categoryNames] of Object.entries(auctionCategories)) { + for (const [quality, qualityNames] of Object.entries(auctionQualities)) add( + `AuctionHouseBot.ListProportion.Category${category}.Quality${quality}`, "int", + categoryNames.map((name, index) => `${name}: ${qualityNames[index]}`), + ["Relative listing weight, not a percentage. 0 excludes this type and quality. Larger values make it more frequent among new listings.", "Относительный вес, а не процент. 0 исключает этот тип и качество. Чем больше значение, тем чаще они встречаются среди новых лотов.", "Relatives Gewicht, kein Prozentwert. 0 schließt Typ und Qualität aus. Höhere Werte erzeugen häufiger neue Angebote.", "Poids relatif, pas un pourcentage. 0 exclut ce type et cette qualité. Une valeur plus élevée augmente leur fréquence.", "Peso relativo, no porcentaje. 0 excluye este tipo y calidad. Valores mayores aumentan su frecuencia en nuevas subastas.", "相对权重,并非百分比。0排除此类型与品质,数值越大越常出现在新拍卖中。"], + { min: 0, max: 1000, advanced: true }, + ); +} +export function moduleField(key: string, locale: Locale): ModuleField | undefined { + const field = fields.find(f => f.key === key); + if (field) return { ...field, title: text(field.title, locale), description: text(field.description, locale), category: "general", advanced: field.advanced ?? false, step: field.type === "range" ? 0.05 : undefined, options: field.options?.map(o => ({ value: o.value, label: text(o.label, locale) })) }; + const existing = [...bots.settings, ...server.settings].find(s => s.key === key); + return existing ? { ...existing, options: "options" in existing ? existing.options : undefined } as ModuleField : undefined; +} + +/** Older configs accept numeric aliases; keep their effective selection when opening the editor. */ +export function canonicalModuleValue(key: string, value: string): string { + const v = value.trim().replace(/^"|"$/g, ""); + if (key === scaling + "SoloAssist.Mode") return ({ "0": "None", "1": "Light", "2": "Full", none: "None", light: "Light", full: "Full" } as Record)[v.toLowerCase()] ?? v; + if (key === scaling + "LFG.DefaultMode") return ({ "0": "Matchmaking", "1": "BotFill", "2": "CurrentParty", matchmaking: "Matchmaking", botfill: "BotFill", currentparty: "CurrentParty" } as Record)[v.toLowerCase()] ?? v; + if (key === scaling + "Progression.Mode") return v.toLowerCase() === "auto" ? "Auto" : v.toLowerCase() === "custom" ? "Custom" : v; + return v; +} diff --git a/src/screens/AuctionTypes.tsx b/src/screens/AuctionTypes.tsx new file mode 100644 index 0000000..fcae181 --- /dev/null +++ b/src/screens/AuctionTypes.tsx @@ -0,0 +1,50 @@ +import { useRef } from "react"; +import { useI18n } from "@/i18n"; +import type { ModuleSetting } from "@/lib/api"; +import { auctionCategories, auctionCategoryLabel, auctionCategorySettings, auctionQualityLabel, auctionTypeChanges, auctionWeightKey, canonicalModuleValue } from "@/lib/moduleSettings"; +import { cn } from "@/lib/utils"; + +export function AuctionTypes({ items, edits, busy, query, errors, onChange }: { + items: ModuleSetting[]; edits: Record; busy: boolean; query: string; + onChange: (changes: Record) => void; + errors: Record; +}) { + const { t, locale } = useI18n(); + const remembered = useRef>>({}); + const groups = Object.keys(auctionCategories).map(category => ({ category, settings: auctionCategorySettings(items, category) })).filter(group => group.settings.length); + if (!groups.length) return null; + const valueOf = (item: ModuleSetting) => canonicalModuleValue(item.key, edits[item.key] ?? item.value); + const matching = groups.filter(group => `${auctionCategoryLabel(group.category, locale)} ${t("mod.ahbot.types")} ${group.settings.map(s => `${s.key} ${auctionQualityLabel(auctionWeightKey(s.key)![2], locale)}`).join(" ")}`.toLocaleLowerCase(locale).includes(query.trim().toLocaleLowerCase(locale))); + if (!matching.length) return null; + return
+ {t("mod.ahbot.types")} +

{t("mod.ahbot.typesHint")}

+
+ {matching.map(({ category, settings }) => { + const on = settings.some(item => Number(valueOf(item)) > 0); + const label = auctionCategoryLabel(category, locale); + return
+
+ {label}{settings.some(s => s.key in edits) && {t("set.changed")}} + +
+
+ {t("mod.ahbot.qualityWeights")} +

{t("mod.ahbot.weightsHint")}

+ {settings.map(item => )} +
+ {settings.filter(item => errors[item.key]).map(item =>

{auctionQualityLabel(auctionWeightKey(item.key)![2], locale)}: {errors[item.key]}

)} +
; + })} +
+ {!groups.some(group => group.settings.some(item => Number(valueOf(item)) > 0)) &&

{t("mod.ahbot.noTypes")}

} +
; +} diff --git a/src/screens/ModulesPage.tsx b/src/screens/ModulesPage.tsx index 7309123..4a7123d 100644 --- a/src/screens/ModulesPage.tsx +++ b/src/screens/ModulesPage.tsx @@ -4,7 +4,9 @@ import { api, asUiError, type ModuleSetting, type ModuleView, type UiError } fro import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import { useHuman, useI18n, type Key } from "@/i18n"; +import { useHuman, useI18n, useSchemaText, type Key } from "@/i18n"; +import { auctionWeightKey, canonicalModuleValue, moduleEnableKey, moduleField } from "@/lib/moduleSettings"; +import { AuctionTypes } from "@/screens/AuctionTypes"; /** The GitHub mark (lucide dropped its brand icons). */ function GithubMark(props: { className?: string }) { @@ -160,7 +162,7 @@ export function ModulesPage({ serverId, onChanged }: { serverId: string; onChang > - {m.installed && m.status !== "soon" && m.compatibility !== "unsupported" && ( + {m.installed && m.has_settings && m.status !== "soon" && m.compatibility !== "unsupported" && ( @@ -168,7 +170,7 @@ export function ModulesPage({ serverId, onChanged }: { serverId: string; onChang {/* The settings open right under the row of the card they belong to, so they are never out of sight below a long grid. */} - {selected && selectedAt >= 0 && Math.floor(selectedAt / 2) === Math.floor(i / 2) && (i % 2 === 1 || i === modules.length - 1) && ( + {selected?.has_settings && selectedAt >= 0 && Math.floor(selectedAt / 2) === Math.floor(i / 2) && (i % 2 === 1 || i === modules.length - 1) && (

{t("mod.settingsOf", { name: selected.name })}

setChanged(true)} /> @@ -182,22 +184,44 @@ export function ModulesPage({ serverId, onChanged }: { serverId: string; onChang } function ModuleSettings({ serverId, module, onSaved }: { serverId: string; module: ModuleView; onSaved: () => void }) { - const { t } = useI18n(); + const { t, locale } = useI18n(); + const sx = useSchemaText(); const human = useHuman(); + const [advanced, setAdvanced] = useState(false); const [items, setItems] = useState(null); const [edits, setEdits] = useState>({}); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [note, setNote] = useState(null); + const [query, setQuery] = useState(""); + const [fieldErrors, setFieldErrors] = useState>({}); useEffect(() => { + let cancelled = false; setItems(null); setEdits({}); setNote(null); - void api.moduleSettings(serverId, module.id).then((s) => setItems(s)).catch((e) => setError(asUiError(e))); + setError(null); + setFieldErrors({}); + void api.moduleSettings(serverId, module.id).then((s) => { if (!cancelled) setItems(s); }).catch((e) => { if (!cancelled) setError(asUiError(e)); }); + return () => { cancelled = true; }; }, [serverId, module.id]); async function save() { + const errors: Record = {}; + for (const [key, value] of Object.entries(edits)) { + const field = moduleField(key, locale); + if (field && ["int", "float", "range"].includes(field.type)) { + const number = Number(value); + const message = !value.trim() || !Number.isFinite(number) ? t("set.enterNumber") + : field.type === "int" && !Number.isInteger(number) ? t("set.enterInt") + : field.min !== undefined && number < field.min ? t("set.atLeast", { n: field.min }) + : field.max !== undefined && number > field.max ? t("set.atMost", { n: field.max }) : null; + if (message) errors[key] = message; + } + } + setFieldErrors(errors); + if (Object.keys(errors).length) return; setBusy(true); setError(null); setNote(null); @@ -217,37 +241,81 @@ function ModuleSettings({ serverId, module, onSaved }: { serverId: string; modul } const dirty = Object.keys(edits).length > 0; + const edit = (key: string, value: string) => setEdits(previous => { + const next = { ...previous }; + if (value === items?.find(item => item.key === key)?.value) delete next[key]; + else next[key] = value; + return next; + }); + const available = items?.filter(s => s.key !== moduleEnableKey(module.id)) ?? []; + const advancedCount = available.filter(s => !auctionWeightKey(s.key) && (!moduleField(s.key, locale) || moduleField(s.key, locale)?.advanced)).length; + const visible = available.filter(s => { + if (module.id === "ah-bot" && auctionWeightKey(s.key)) return false; + const field = moduleField(s.key, locale); + if (!advanced && !query.trim() && (!field || field.advanced)) return false; + const title = field ? sx.title({ key: s.key, title: field.title }) : s.key; + const description = field ? sx.description({ key: s.key, description: field.description }) : s.doc; + return `${title} ${description} ${s.key}`.toLocaleLowerCase(locale).includes(query.trim().toLocaleLowerCase(locale)); + }); return (
{items === null && !error && } + {module.id === "ah-bot" && ( +
+

{t("mod.ahbot.setup")}

+
+ )} + {available.length >= 10 && setQuery(e.target.value)} aria-label={t("set.search")} placeholder={t("set.search")} className="mb-3 w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none focus:border-gold" />} + {advancedCount > 0 && } + {module.id === "ah-bot" && setEdits(previous => { + const next = { ...previous }; + for (const [key, value] of Object.entries(changes)) { + if (value === items?.find(item => item.key === key)?.value) delete next[key]; + else next[key] = value; + } + return next; + })} />} + {items && !visible.length && !available.some(s => module.id === "ah-bot" && auctionWeightKey(s.key)) &&

{t(available.length ? "all.none" : "mod.noSettings")}

}
    - {items?.map((s) => ( + {visible.map((s) => { + const field = moduleField(s.key, locale); + const title = field ? sx.title({ key: s.key, title: field.title }) : s.key; + const description = field ? sx.description({ key: s.key, description: field.description }) : s.doc; + const value = edits[s.key] ?? s.value; + const normalized = canonicalModuleValue(s.key, value); + const options = field?.options ?? []; + const bool = field?.type === "bool" || /^(true|false)$/i.test(normalized) || (/^(0|1)$/.test(normalized) && /(?:\.Enable(?:d)?$|Enable\/Disable)/i.test(s.key + " " + s.doc)); + const on = /^(1|true|yes|on)$/i.test(normalized); + const id = `${module.id}-${s.key}`; + const controlClass = "selectable w-56 rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none focus:border-gold"; + return (
  • - - setEdits((x) => ({ ...x, [s.key]: e.target.value }))} - className="selectable w-56 rounded-md border border-line bg-bg px-2.5 py-1.5 text-sm outline-none focus:border-gold" - /> +
    {s.key in edits && {t("set.changed")}}
    + {bool ? + : options.length ? + : field?.type === "range" ?
    edit(s.key, e.target.value)} aria-valuetext={`${Math.round(Number(normalized) * 100)}%`} className="min-w-0 flex-1 accent-gold" />{Math.round(Number(normalized) * 100)}%
    + : edit(s.key, e.target.value)} className={controlClass} />}
    - {s.doc &&

    {s.doc}

    } + {description &&

    {description}

    } + {fieldErrors[s.key] &&

    {fieldErrors[s.key]}

    } + {advanced &&

    {s.key}

    } {s.default !== null && (edits[s.key] ?? s.value) !== s.default && ( - )}
  • - ))} + ); })}
-
+ {available.length > 0 &&
+ {dirty && } {note && {note}} -
+
} {error &&

{error.human.code === "unknown" ? error.technical : human(error.human).message}

}
); diff --git a/tools/check-module-settings.mjs b/tools/check-module-settings.mjs new file mode 100644 index 0000000..61ab6b9 --- /dev/null +++ b/tools/check-module-settings.mjs @@ -0,0 +1,42 @@ +// Validate the module controls independently of the server/bot schema overlays. +import { readFileSync } from "node:fs"; +import { stripTypeScriptTypes } from "node:module"; +const root = new URL("../", import.meta.url); +let source = readFileSync(new URL("src/lib/moduleSettings.ts", root), "utf8"); +source = source.replace(/import (\w+) from "(\.\.\/\.\.\/[^"\n]+\.json)";/g, (_, name, path) => { + const data = JSON.parse(readFileSync(new URL(path, new URL("src/lib/", root)), "utf8")); + return `const ${name} = ${JSON.stringify(data)};`; +}); +const compiled = stripTypeScriptTypes(source); +const { moduleSettingDefinitions, moduleField, canonicalModuleValue, auctionTypeChanges, auctionCategorySettings, auctionCategories, auctionQualities } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString("base64")}`); +const ids = new Set(); +for (const field of moduleSettingDefinitions) { + if (ids.has(field.key)) throw new Error(`Duplicate module setting: ${field.key}`); + ids.add(field.key); + for (const [label, translations] of [["title", field.title], ["description", field.description], ...(field.options ?? []).map(o => [o.value, o.label])]) { + if (translations.length !== 6 || translations.some(t => !t?.trim())) throw new Error(`${field.key}: incomplete ${label}`); + } + if (field.min !== undefined && field.max !== undefined && field.min > field.max) throw new Error(`${field.key}: invalid range`); +} +for (const [value, expected] of [["0", "None"], ["1", "Light"], ["2", "Full"], ['"light"', "Light"]]) { + if (canonicalModuleValue("CoAContentScaling.SoloAssist.Mode", value) !== expected) throw new Error(`Solo mode alias: ${value}`); +} +const difficulty = moduleField("CoAContentScaling.Difficulty.DamageMultiplier", "ru"); +if (difficulty.type !== "range" || difficulty.min !== 0.25 || difficulty.max !== 2) throw new Error("Damage slider bounds disagree with the server"); +const sample = [ + { key: "AuctionHouseBot.ListProportion.CategoryWeapon.QualityNormal", value: "20", default: "10" }, + { key: "AuctionHouseBot.ListProportion.CategoryWeapon.QualityRare", value: "7", default: "5" }, + { key: "AuctionHouseBot.ListProportion.CategoryArmor.QualityNormal", value: "50", default: "50" }, +]; +const weapon = auctionCategorySettings(sample, "Weapon"); +const off = auctionTypeChanges(weapon, false); +if (Object.keys(off).length !== 2 || Object.values(off).some(v => v !== "0")) throw new Error("Turning off a type must exclude all its qualities, and only that type"); +const remembered = Object.fromEntries(weapon.map(item => [item.key, item.value])); +if (JSON.stringify(auctionTypeChanges(weapon, true, remembered)) !== JSON.stringify(remembered)) throw new Error("Custom weights must survive off/on"); +const defaults = auctionTypeChanges(weapon, true); +if (defaults[weapon[0].key] !== "10" || defaults[weapon[1].key] !== "5") throw new Error("Enabling a type must restore documented weights"); +const zero = weapon.map(item => ({...item, value: "0", default: "0"})); +const enabledZero = auctionTypeChanges(zero, true); +if (enabledZero[weapon[0].key] !== "1" || enabledZero[weapon[1].key] !== "0") throw new Error("Zero-default types must become enabled without adding missing keys"); +for (const label of [...Object.values(auctionCategories), ...Object.values(auctionQualities)]) if (label.length !== 6 || label.some(t => !t)) throw new Error("Incomplete auction type/quality translation"); +console.log(`${ids.size} module controls: all six languages, unique keys, ranges and legacy aliases verified`);