Merge pull request #66 from Corfirean/feat/custom-races-bot-exclusions

feat(races): synchronize custom races bot setting with playerbots excluded races
This commit is contained in:
Corfirean authored and GitHub committed 2026-10-11 01:31:20 +05:00
commit 2abd64dfe2
4 files changed
+164 -7

No files matched your search

@@ -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).
+79
View File
@@ -54,6 +54,85 @@ pub const RACE_MPQS: &[&str] = &[
"patch-ZZR.MPQ",
];
pub const EXCLUDED_BOT_RACES_RECOMMENDED: &str =
"19,27,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 =
"16,19,27,32,50,52,53,65,66,67,68,69,70,71,72,74,77,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<PathBuf> {
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<bool> {
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 {
+70 -6
View File
@@ -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<Vec<Setting>> {
}
}
}
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");
}
}
+8 -1
View File
@@ -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.";