diff --git a/changelog.d/20261010-custom-races-bot-exclusions.md b/changelog.d/20261010-custom-races-bot-exclusions.md new file mode 100644 index 0000000..29fb894 --- /dev/null +++ b/changelog.d/20261010-custom-races-bot-exclusions.md @@ -0,0 +1,7 @@ +--- +area: manager +type: fixed +audience: admins +title: Custom races bot setting synchronizes Playerbots excluded race lists +--- +The "Bots can be custom races" setting now configures AiPlayerbot.ExcludedBotRaces in playerbots.conf. Off excludes all added races (recommended), while on allows stable added races (experimental). diff --git a/crates/coa-core/src/custom_races.rs b/crates/coa-core/src/custom_races.rs index c675e4d..cecb935 100644 --- a/crates/coa-core/src/custom_races.rs +++ b/crates/coa-core/src/custom_races.rs @@ -54,6 +54,85 @@ pub const RACE_MPQS: &[&str] = &[ "patch-ZZR.MPQ", ]; +pub const EXCLUDED_BOT_RACES_RECOMMENDED: &str = + "19,27,65,72,77,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128"; + +pub const EXCLUDED_BOT_RACES_EXPERIMENTAL: &str = + "19,27,65,72,77,16,50,67,32,66,68,69,70,71,74,52,53,80,81"; + +/// Finds all active playerbots.conf files on this server (root, coa-bots folder, and secondary realm). +pub fn playerbots_conf_paths(root: &Path) -> Vec { + let mut paths = Vec::new(); + let candidates = [ + root.join("Core/configs/modules/playerbots.conf"), + root.join("coa-bots/Core/configs/modules/playerbots.conf"), + root.join("coa-bots/core/configs/modules/playerbots.conf"), + crate::multiworld::root(root).join("Core/configs/modules/playerbots.conf"), + ]; + for path in candidates { + if path.is_file() { + paths.push(path); + } + } + if paths.is_empty() { + for dist in [ + root.join("Core/configs/modules/playerbots.conf.dist"), + root.join("coa-bots/Core/configs/modules/playerbots.conf.dist"), + ] { + if dist.is_file() { + let target = dist.with_extension(""); + if fs::copy(&dist, &target).is_ok() { + paths.push(target); + break; + } + } + } + } + paths +} + +/// Checks whether playerbots are configured to allow custom races. +/// Returns true if AiPlayerbot.ExcludedBotRaces exists and does not exclude general custom races. +pub fn is_bot_custom_races_allowed(root: &Path) -> Option { + for path in playerbots_conf_paths(root) { + if let Ok(bytes) = fs::read(&path) { + if let Ok(conf) = crate::config::parser::ConfFile::parse_bytes(&bytes) { + if let Some(val) = conf.get("AiPlayerbot.ExcludedBotRaces") { + let clean = val.trim().trim_matches(['"', '\'']); + return Some(!clean.contains("33") && !clean.contains("128")); + } + } + } + } + None +} + +/// Sets AiPlayerbot.ExcludedBotRaces in all discovered playerbots.conf files. +pub fn sync_bot_races_config(root: &Path, allow_custom_races: bool) -> Result<()> { + let excluded = if allow_custom_races { + EXCLUDED_BOT_RACES_EXPERIMENTAL + } else { + EXCLUDED_BOT_RACES_RECOMMENDED + }; + for path in playerbots_conf_paths(root) { + if let Ok(bytes) = fs::read(&path) { + if let Ok(mut conf) = crate::config::parser::ConfFile::parse_bytes(&bytes) { + conf.set( + "AiPlayerbot.ExcludedBotRaces", + &format!("\"{excluded}\""), + &[ + "CoA Custom: races new random bots never get", + "Off (recommended): excludes all added races", + "On (experimental): allows stable added races", + ], + ); + fsx::atomic_write(&path, conf.to_text().as_bytes())?; + } + } + } + Ok(()) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ClientPatchState { diff --git a/crates/coa-core/src/modules.rs b/crates/coa-core/src/modules.rs index ba7894b..b6d0bdd 100644 --- a/crates/coa-core/src/modules.rs +++ b/crates/coa-core/src/modules.rs @@ -214,6 +214,16 @@ pub fn set_enabled(root: &Path, meta: &Path, id: &str, on: bool) -> Result<()> { } } } + if !on { + let _ = crate::custom_races::sync_bot_races_config(root, false); + } else { + let allow = crate::custom_races::is_bot_custom_races_allowed(root).unwrap_or_else(|| { + read(&dir(root).join("coa_custom_races.conf")) + .and_then(|c| c.get("CoACustomRaces.BotsEnable").map(truthy)) + .unwrap_or(false) + }); + let _ = crate::custom_races::sync_bot_races_config(root, allow); + } } set_enabled_in(&catalog(), root, meta, id, on) } @@ -290,14 +300,27 @@ fn settings_in(cat: &[Entry], root: &Path, id: &str) -> Result> { } } } + let bot_custom_races_allowed = if id == "custom-races" { + crate::custom_races::is_bot_custom_races_allowed(root) + } else { + None + }; Ok(effective .entries() - .map(|(k, v)| Setting { - key: k.to_string(), - value: fields.get(k).and_then(|field| field.default_if_missing.as_ref()).filter(|_| active.get(k).is_none()).and_then(crate::squid::scalar).unwrap_or_else(|| v.to_string()), - default: fields.get(k).and_then(|field| crate::squid::scalar(&field.default)).or_else(|| dist.as_ref().and_then(|d| d.get(k)).map(str::to_string)), - field: fields.get(k).cloned(), - doc: dist.as_ref().map(|d| d.doc_for(k)).filter(|d| !d.is_empty()).unwrap_or_else(|| active.doc_for(k)).join(" "), + .map(|(k, v)| { + let mut val = fields.get(k).and_then(|field| field.default_if_missing.as_ref()).filter(|_| active.get(k).is_none()).and_then(crate::squid::scalar).unwrap_or_else(|| v.to_string()); + if k == "CoACustomRaces.BotsEnable" { + if let Some(allowed) = bot_custom_races_allowed { + val = if allowed { "1".into() } else { "0".into() }; + } + } + Setting { + key: k.to_string(), + value: val, + default: fields.get(k).and_then(|field| crate::squid::scalar(&field.default)).or_else(|| dist.as_ref().and_then(|d| d.get(k)).map(str::to_string)), + field: fields.get(k).cloned(), + doc: dist.as_ref().map(|d| d.doc_for(k)).filter(|d| !d.is_empty()).unwrap_or_else(|| active.doc_for(k)).join(" "), + } }) .collect()) } @@ -353,6 +376,12 @@ fn save_settings_in(cat: &[Entry], root: &Path, meta: &Path, id: &str, changes: changed.push((key.clone(), value.to_string())); } } + if id == "custom-races" { + if let Some(bots_enable) = changes.get("CoACustomRaces.BotsEnable") { + let allow = truthy(bots_enable); + crate::custom_races::sync_bot_races_config(root, allow)?; + } + } if changed.is_empty() { return Ok(Vec::new()); } @@ -672,4 +701,39 @@ mod tests { assert!(fs::read_to_string(dir(&root).join("war_games.conf")).unwrap().contains("WarGames.Enable = 0")); assert!(!list_in(&cat, &root).into_iter().find(|m| m.id == "war-games").unwrap().enabled); } + #[test] + fn custom_races_bot_settings_synchronize_playerbots_conf() { + let d = tempfile::tempdir().unwrap(); + let (root, meta) = server(d.path()); + let cat = catalog(); + fs::write(dir(&root).join("coa_custom_races.conf.dist"), "CoACustomRaces.Enable = 1\nCoACustomRaces.BotsEnable = 0\n").unwrap(); + fs::write(dir(&root).join("coa_custom_races.conf"), "CoACustomRaces.Enable = 1\nCoACustomRaces.BotsEnable = 0\n").unwrap(); + fs::write(dir(&root).join("playerbots.conf"), "AiPlayerbot.Enabled = 1\n").unwrap(); + + // 1. Initially CoACustomRaces.BotsEnable is 0 + let s = settings_in(&cat, &root, "custom-races").unwrap(); + assert_eq!(s.iter().find(|s| s.key == "CoACustomRaces.BotsEnable").unwrap().value, "0"); + + // 2. Enable bots for custom races: should write experimental exclusions to playerbots.conf + let change_on = BTreeMap::from([("CoACustomRaces.BotsEnable".into(), "1".into())]); + assert_eq!(save_settings_in(&cat, &root, &meta, "custom-races", &change_on).unwrap(), ["CoACustomRaces.BotsEnable"]); + let pb = fs::read_to_string(dir(&root).join("playerbots.conf")).unwrap(); + assert!(pb.contains("AiPlayerbot.ExcludedBotRaces")); + assert!(pb.contains(crate::custom_races::EXCLUDED_BOT_RACES_EXPERIMENTAL)); + + // Settings view now sees "1" + let s = settings_in(&cat, &root, "custom-races").unwrap(); + assert_eq!(s.iter().find(|s| s.key == "CoACustomRaces.BotsEnable").unwrap().value, "1"); + + // 3. Disable bots for custom races: should write recommended exclusions to playerbots.conf + let change_off = BTreeMap::from([("CoACustomRaces.BotsEnable".into(), "0".into())]); + assert_eq!(save_settings_in(&cat, &root, &meta, "custom-races", &change_off).unwrap(), ["CoACustomRaces.BotsEnable"]); + let pb = fs::read_to_string(dir(&root).join("playerbots.conf")).unwrap(); + assert!(pb.contains(crate::custom_races::EXCLUDED_BOT_RACES_RECOMMENDED)); + + // Settings view now sees "0" + let s = settings_in(&cat, &root, "custom-races").unwrap(); + assert_eq!(s.iter().find(|s| s.key == "CoACustomRaces.BotsEnable").unwrap().value, "0"); + } } + diff --git a/src/lib/moduleSettings.ts b/src/lib/moduleSettings.ts index 764645a..78e5117 100644 --- a/src/lib/moduleSettings.ts +++ b/src/lib/moduleSettings.ts @@ -87,7 +87,14 @@ for (let n = 1; n <= 3; n++) add(`EC.P${n}`, "string", [`NPC phrase ${n}`, `Ре add("CoACustomRaces.BotsEnable", "bool", ["Bots can be custom races", "Боты кастомных рас", "Bots benutzerdefinierter Völker", "Bots de races personnalisées", "Bots de razas personalizadas", "允许机器人为自定义种族"], - ["Allow Playerbots to spawn as custom races.", "Разрешает ботам-игрокам появляться кастомными расами.", "Erlaubt Spieler-Bots, benutzerdefinierte Völker zu nutzen.", "Autorise les bots joueurs à s’incarner avec des races personnalisées.", "Permite a los bots jugadores crearse con razas personalizadas.", "允许玩家机器人扮演自定义种族。"] + [ + "Allow Playerbots to spawn as custom races (experimental). Off excludes all custom races; on allows stable custom races.", + "Разрешает ботам-игрокам появляться кастомными расами (экспериментально). Выключено — исключает все кастомные расы, включено — разрешает стабильные.", + "Erlaubt Spieler-Bots, benutzerdefinierte Völker zu nutzen (experimentell). Aus schließt alle benutzerdefinierten Völker aus; An erlaubt stabile.", + "Autorise les bots joueurs à s’incarner avec des races personnalisées (expérimental). Désactivé exclut toutes les races personnalisées ; activé autorise les stables.", + "Permite a los bots jugadores crearse con razas personalizadas (experimental). Desactivado excluye todas las razas personalizadas; activado permite las estables.", + "允许玩家机器人扮演自定义种族(实验性)。关闭将排除所有自定义种族,开启允许稳定的自定义种族。" + ] ); const ah = "AuctionHouseBot.";