From 7cc00e6283ff1145aea5423d175bcb6048203e83 Mon Sep 17 00:00:00 2001 From: kadeshar Date: Sat, 11 Apr 2026 07:15:47 +0200 Subject: [PATCH 1/7] Focus heal targets strategy migration (#2254) ## Pull Request Description Migration for "focus heal targets" strategy which order healer to focus only on specified targets Commands: `nc +focus heal targets` `co +focus heal targets` `focus heal +botName` `focus heal -botName` `focus heal ?` ## How to Test the Changes - create bots party with at least one healer - apply to healer strategy - order healer focus on single bot - start fight (for example with dummy) - use `.damage 10000` to bots a watch which are healed directly and which one only via aoe healing ## Impact Assessment - Does this change increase per-bot/per-tick processing or risk scaling poorly with thousands of bots? - - [x] No, not at all - - [ ] Minimal impact (**explain below**) - - [ ] Moderate impact (**explain below**) - Does this change modify default bot behavior? - - [x] No - - [ ] Yes (**explain why**) - Does this change add new decision branches or increase maintenance complexity? - - [x] No - - [ ] Yes (**explain below**) ## Messages to Translate - Does this change add bot messages to translate? - - [ ] No - - [x] Yes (**list messages in the table**) | Message key | Default message | | --------------- | ------------------ | focus_heal_not_healer | I''m not a healer or offhealer (please change my strats to heal or offheal) focus_heal_provide_names | Please provide one or more player names focus_heal_no_targets | I don''t have any focus heal targets focus_heal_current_targets | My focus heal targets are %targets focus_heal_cleared | Removed focus heal targets focus_heal_add_remove_syntax | Please specify a + for add or - to remove a target focus_heal_not_in_group | I''m not in a group focus_heal_not_in_group_with | I''m not in a group with %player_name focus_heal_added | Added %player_name to focus heal targets focus_heal_removed | Removed %player_name from focus heal targets ## AI Assistance - Was AI assistance used while working on this change? - - [ ] No - - [x] Yes (**explain below**) OpenCode, to help migrate strategy ## Final Checklist - - [x] Stability is not compromised. - - [x] Performance impact is understood, tested, and acceptable. - - [x] Added logic complexity is justified and explained. - - [ ] Documentation updated if needed (Conf comments, WiKi commands). ## Notes for Reviewers From tests: obraz Its optional strategy mostly for raiders then performance is not a issue. --------- Co-authored-by: Keleborn <22352763+Celandriel@users.noreply.github.com> Co-authored-by: bash Co-authored-by: Revision --- ...03_28_00_ai_playerbot_focus_heal_texts.sql | 240 ++++++++++++++++++ .../Actions/SetFocusHealTargetsAction.cpp | 219 ++++++++++++++++ .../Base/Actions/SetFocusHealTargetsAction.h | 21 ++ src/Ai/Base/ChatActionContext.h | 3 + src/Ai/Base/ChatTriggerContext.h | 2 + .../Strategy/ChatCommandHandlerStrategy.cpp | 2 + src/Ai/Base/Strategy/FocusTargetStrategy.h | 20 ++ src/Ai/Base/StrategyContext.h | 3 + src/Ai/Base/Value/PartyMemberToHeal.cpp | 50 +++- src/Ai/Base/Value/TargetValue.h | 7 + src/Ai/Base/ValueContext.h | 2 + 11 files changed, 559 insertions(+), 10 deletions(-) create mode 100644 data/sql/playerbots/updates/2026_03_28_00_ai_playerbot_focus_heal_texts.sql create mode 100644 src/Ai/Base/Actions/SetFocusHealTargetsAction.cpp create mode 100644 src/Ai/Base/Actions/SetFocusHealTargetsAction.h create mode 100644 src/Ai/Base/Strategy/FocusTargetStrategy.h diff --git a/data/sql/playerbots/updates/2026_03_28_00_ai_playerbot_focus_heal_texts.sql b/data/sql/playerbots/updates/2026_03_28_00_ai_playerbot_focus_heal_texts.sql new file mode 100644 index 00000000000..3d356a2a2ba --- /dev/null +++ b/data/sql/playerbots/updates/2026_03_28_00_ai_playerbot_focus_heal_texts.sql @@ -0,0 +1,240 @@ +-- ######################################################### +-- Playerbots - Add focus heal command texts +-- Localized for all WotLK locales (koKR, frFR, deDE, zhCN, +-- zhTW, esES, esMX, ruRU) +-- ######################################################### + +DELETE FROM ai_playerbot_texts WHERE name IN ( + 'focus_heal_not_healer', + 'focus_heal_provide_names', + 'focus_heal_no_targets', + 'focus_heal_current_targets', + 'focus_heal_cleared', + 'focus_heal_add_remove_syntax', + 'focus_heal_not_in_group', + 'focus_heal_not_in_group_with', + 'focus_heal_added', + 'focus_heal_removed' +); +DELETE FROM ai_playerbot_texts_chance WHERE name IN ( + 'focus_heal_not_healer', + 'focus_heal_provide_names', + 'focus_heal_no_targets', + 'focus_heal_current_targets', + 'focus_heal_cleared', + 'focus_heal_add_remove_syntax', + 'focus_heal_not_in_group', + 'focus_heal_not_in_group_with', + 'focus_heal_added', + 'focus_heal_removed' +); + +-- focus_heal_not_healer +INSERT INTO `ai_playerbot_texts` + (`id`, `name`, `text`, `say_type`, `reply_type`, + `text_loc1`, `text_loc2`, `text_loc3`, `text_loc4`, + `text_loc5`, `text_loc6`, `text_loc7`, `text_loc8`) +VALUES ( + 1745, + 'focus_heal_not_healer', + 'I''m not a healer or offhealer (please change my strats to heal or offheal)', + 0, 0, + '저는 힐러나 오프힐러가 아닙니다 (전략을 heal 또는 offheal로 변경해주세요)', + 'Je ne suis pas un soigneur ou un soigneur secondaire (veuillez changer mes strats en heal ou offheal)', + 'Ich bin kein Heiler oder Nebenheiler (bitte ändere meine Strategien auf heal oder offheal)', + '我不是治疗者或副治疗者(请将我的策略更改为 heal 或 offheal)', + '我不是治療者或副治療者(請將我的策略更改為 heal 或 offheal)', + 'No soy un sanador ni un sanador secundario (por favor cambia mis estrategias a heal o offheal)', + 'No soy un sanador ni un sanador secundario (por favor cambia mis estrategias a heal o offheal)', + 'Я не лекарь и не побочный лекарь (пожалуйста, измените мои стратегии на heal или offheal)'); + +INSERT INTO ai_playerbot_texts_chance (name, probability) VALUES ('focus_heal_not_healer', 100); + +-- focus_heal_provide_names +INSERT INTO `ai_playerbot_texts` + (`id`, `name`, `text`, `say_type`, `reply_type`, + `text_loc1`, `text_loc2`, `text_loc3`, `text_loc4`, + `text_loc5`, `text_loc6`, `text_loc7`, `text_loc8`) +VALUES ( + 1746, + 'focus_heal_provide_names', + 'Please provide one or more player names', + 0, 0, + '하나 이상의 플레이어 이름을 제공해주세요', + 'Veuillez fournir un ou plusieurs noms de joueurs', + 'Bitte geben Sie einen oder mehrere Spielernamen an', + '请提供一个或多个玩家名称', + '請提供一個或多個玩家名稱', + 'Por favor proporciona uno o más nombres de jugadores', + 'Por favor proporciona uno o más nombres de jugadores', + 'Пожалуйста, укажите одно или несколько имён игроков'); + +INSERT INTO ai_playerbot_texts_chance (name, probability) VALUES ('focus_heal_provide_names', 100); + +-- focus_heal_no_targets +INSERT INTO `ai_playerbot_texts` + (`id`, `name`, `text`, `say_type`, `reply_type`, + `text_loc1`, `text_loc2`, `text_loc3`, `text_loc4`, + `text_loc5`, `text_loc6`, `text_loc7`, `text_loc8`) +VALUES ( + 1747, + 'focus_heal_no_targets', + 'I don''t have any focus heal targets', + 0, 0, + '지정된 집중 치유 대상이 없습니다', + 'Je n''ai aucune cible de soin prioritaire', + 'Ich habe keine fokussierten Heilziele', + '我没有任何集中治疗目标', + '我沒有任何集中治療目標', + 'No tengo ningún objetivo de sanación prioritario', + 'No tengo ningún objetivo de sanación prioritario', + 'У меня нет целей приоритетного лечения'); + +INSERT INTO ai_playerbot_texts_chance (name, probability) VALUES ('focus_heal_no_targets', 100); + +-- focus_heal_current_targets: %targets is replaced with comma-separated player names +INSERT INTO `ai_playerbot_texts` + (`id`, `name`, `text`, `say_type`, `reply_type`, + `text_loc1`, `text_loc2`, `text_loc3`, `text_loc4`, + `text_loc5`, `text_loc6`, `text_loc7`, `text_loc8`) +VALUES ( + 1748, + 'focus_heal_current_targets', + 'My focus heal targets are %targets', + 0, 0, + '나의 집중 치유 대상: %targets', + 'Mes cibles de soin prioritaire sont %targets', + 'Meine fokussierten Heilziele sind %targets', + '我的集中治疗目标是 %targets', + '我的集中治療目標是 %targets', + 'Mis objetivos de sanación prioritarios son %targets', + 'Mis objetivos de sanación prioritarios son %targets', + 'Мои цели приоритетного лечения: %targets'); + +INSERT INTO ai_playerbot_texts_chance (name, probability) VALUES ('focus_heal_current_targets', 100); + +-- focus_heal_cleared +INSERT INTO `ai_playerbot_texts` + (`id`, `name`, `text`, `say_type`, `reply_type`, + `text_loc1`, `text_loc2`, `text_loc3`, `text_loc4`, + `text_loc5`, `text_loc6`, `text_loc7`, `text_loc8`) +VALUES ( + 1749, + 'focus_heal_cleared', + 'Removed focus heal targets', + 0, 0, + '집중 치유 대상을 제거했습니다', + 'Cibles de soin prioritaire supprimées', + 'Fokussierte Heilziele entfernt', + '已移除集中治疗目标', + '已移除集中治療目標', + 'Objetivos de sanación prioritarios eliminados', + 'Objetivos de sanación prioritarios eliminados', + 'Цели приоритетного лечения удалены'); + +INSERT INTO ai_playerbot_texts_chance (name, probability) VALUES ('focus_heal_cleared', 100); + +-- focus_heal_add_remove_syntax +INSERT INTO `ai_playerbot_texts` + (`id`, `name`, `text`, `say_type`, `reply_type`, + `text_loc1`, `text_loc2`, `text_loc3`, `text_loc4`, + `text_loc5`, `text_loc6`, `text_loc7`, `text_loc8`) +VALUES ( + 1750, + 'focus_heal_add_remove_syntax', + 'Please specify a + for add or - to remove a target', + 0, 0, + '대상을 추가하려면 +, 제거하려면 -를 지정해주세요', + 'Veuillez spécifier + pour ajouter ou - pour retirer une cible', + 'Bitte geben Sie + zum Hinzufügen oder - zum Entfernen eines Ziels an', + '请指定 + 添加或 - 移除目标', + '請指定 + 添加或 - 移除目標', + 'Por favor especifica + para agregar o - para eliminar un objetivo', + 'Por favor especifica + para agregar o - para eliminar un objetivo', + 'Пожалуйста, укажите + для добавления или - для удаления цели'); + +INSERT INTO ai_playerbot_texts_chance (name, probability) VALUES ('focus_heal_add_remove_syntax', 100); + +-- focus_heal_not_in_group +INSERT INTO `ai_playerbot_texts` + (`id`, `name`, `text`, `say_type`, `reply_type`, + `text_loc1`, `text_loc2`, `text_loc3`, `text_loc4`, + `text_loc5`, `text_loc6`, `text_loc7`, `text_loc8`) +VALUES ( + 1751, + 'focus_heal_not_in_group', + 'I''m not in a group', + 0, 0, + '저는 파티에 속해있지 않습니다', + 'Je ne suis pas dans un groupe', + 'Ich bin in keiner Gruppe', + '我不在队伍中', + '我不在隊伍中', + 'No estoy en un grupo', + 'No estoy en un grupo', + 'Я не в группе'); + +INSERT INTO ai_playerbot_texts_chance (name, probability) VALUES ('focus_heal_not_in_group', 100); + +-- focus_heal_not_in_group_with: %player_name is replaced with the target player's name +INSERT INTO `ai_playerbot_texts` + (`id`, `name`, `text`, `say_type`, `reply_type`, + `text_loc1`, `text_loc2`, `text_loc3`, `text_loc4`, + `text_loc5`, `text_loc6`, `text_loc7`, `text_loc8`) +VALUES ( + 1752, + 'focus_heal_not_in_group_with', + 'I''m not in a group with %player_name', + 0, 0, + '%player_name 와(과) 같은 파티에 없습니다', + 'Je ne suis pas dans un groupe avec %player_name', + 'Ich bin nicht in einer Gruppe mit %player_name', + '我与 %player_name 不在同一队伍中', + '我與 %player_name 不在同一隊伍中', + 'No estoy en un grupo con %player_name', + 'No estoy en un grupo con %player_name', + 'Я не в группе с %player_name'); + +INSERT INTO ai_playerbot_texts_chance (name, probability) VALUES ('focus_heal_not_in_group_with', 100); + +-- focus_heal_added: %player_name is replaced with the added player's name +INSERT INTO `ai_playerbot_texts` + (`id`, `name`, `text`, `say_type`, `reply_type`, + `text_loc1`, `text_loc2`, `text_loc3`, `text_loc4`, + `text_loc5`, `text_loc6`, `text_loc7`, `text_loc8`) +VALUES ( + 1753, + 'focus_heal_added', + 'Added %player_name to focus heal targets', + 0, 0, + '%player_name 을(를) 집중 치유 대상에 추가했습니다', + '%player_name ajouté aux cibles de soin prioritaire', + '%player_name zu den fokussierten Heilzielen hinzugefügt', + '已将 %player_name 添加到集中治疗目标', + '已將 %player_name 添加到集中治療目標', + '%player_name agregado a los objetivos de sanación prioritarios', + '%player_name agregado a los objetivos de sanación prioritarios', + '%player_name добавлен в цели приоритетного лечения'); + +INSERT INTO ai_playerbot_texts_chance (name, probability) VALUES ('focus_heal_added', 100); + +-- focus_heal_removed: %player_name is replaced with the removed player's name +INSERT INTO `ai_playerbot_texts` + (`id`, `name`, `text`, `say_type`, `reply_type`, + `text_loc1`, `text_loc2`, `text_loc3`, `text_loc4`, + `text_loc5`, `text_loc6`, `text_loc7`, `text_loc8`) +VALUES ( + 1754, + 'focus_heal_removed', + 'Removed %player_name from focus heal targets', + 0, 0, + '%player_name 을(를) 집중 치유 대상에서 제거했습니다', + '%player_name retiré des cibles de soin prioritaire', + '%player_name aus den fokussierten Heilzielen entfernt', + '已将 %player_name 从集中治疗目标中移除', + '已將 %player_name 從集中治療目標中移除', + '%player_name eliminado de los objetivos de sanación prioritarios', + '%player_name eliminado de los objetivos de sanación prioritarios', + '%player_name удалён из целей приоритетного лечения'); + +INSERT INTO ai_playerbot_texts_chance (name, probability) VALUES ('focus_heal_removed', 100); diff --git a/src/Ai/Base/Actions/SetFocusHealTargetsAction.cpp b/src/Ai/Base/Actions/SetFocusHealTargetsAction.cpp new file mode 100644 index 00000000000..d987bdd7385 --- /dev/null +++ b/src/Ai/Base/Actions/SetFocusHealTargetsAction.cpp @@ -0,0 +1,219 @@ +/* + * Copyright (C) 2016+ AzerothCore , released under GNU AGPL v3 license, you may redistribute it + * and/or modify it under version 3 of the License, or (at your option), any later version. + */ + +#include "SetFocusHealTargetsAction.h" + +#include "ObjectAccessor.h" +#include "Playerbots.h" +#include "PlayerbotTextMgr.h" + +#include +#include + +static std::string LowercaseString(std::string const& str) +{ + std::string result = str; + std::transform(result.begin(), result.end(), result.begin(), + [](unsigned char c) { return std::tolower(c); }); + return result; +} + +static Player* FindGroupPlayerByName(Player* player, std::string const& playerName) +{ + if (!player) + return nullptr; + + Group* group = player->GetGroup(); + if (!group) + return nullptr; + + for (GroupReference* gref = group->GetFirstMember(); gref; gref = gref->next()) + { + Player* member = gref->GetSource(); + if (member) + { + std::string memberName = member->GetName(); + if (LowercaseString(memberName) == playerName) + return member; + } + } + + return nullptr; +} + +bool SetFocusHealTargetsAction::Execute(Event event) +{ + if (!botAI->IsHeal(bot) && !botAI->HasStrategy("offheal", BOT_STATE_COMBAT)) + { + std::string text = PlayerbotTextMgr::instance().GetBotTextOrDefault( + "focus_heal_not_healer", + "I'm not a healer or offhealer (please change my strats to heal or offheal)", + {}); + botAI->TellMasterNoFacing(text); + return false; + } + + std::string const param = LowercaseString(event.getParam()); + if (param.empty()) + { + std::string text = PlayerbotTextMgr::instance().GetBotTextOrDefault( + "focus_heal_provide_names", + "Please provide one or more player names", + {}); + botAI->TellMasterNoFacing(text); + return false; + } + + std::list focusHealTargets = + AI_VALUE(std::list, "focus heal targets"); + + // Query current focus targets + if (param.find('?') != std::string::npos) + { + if (focusHealTargets.empty()) + { + std::string text = PlayerbotTextMgr::instance().GetBotTextOrDefault( + "focus_heal_no_targets", + "I don't have any focus heal targets", + {}); + botAI->TellMasterNoFacing(text); + } + else + { + std::stringstream targetNames; + for (auto it = focusHealTargets.begin(); it != focusHealTargets.end(); ++it) + { + Unit* target = botAI->GetUnit(*it); + if (target) + { + if (it != focusHealTargets.begin()) + targetNames << ", "; + targetNames << target->GetName(); + } + } + + std::map placeholders; + placeholders["%targets"] = targetNames.str(); + std::string text = PlayerbotTextMgr::instance().GetBotTextOrDefault( + "focus_heal_current_targets", + "My focus heal targets are %targets", + placeholders); + botAI->TellMasterNoFacing(text); + } + + return true; + } + + // Clear all targets + if (param == "none" || param == "unset" || param == "clear") + { + focusHealTargets.clear(); + SET_AI_VALUE(std::list, "focus heal targets", focusHealTargets); + botAI->ChangeStrategy("-focus heal targets", BOT_STATE_COMBAT); + std::string text = PlayerbotTextMgr::instance().GetBotTextOrDefault( + "focus_heal_cleared", + "Removed focus heal targets", + {}); + botAI->TellMasterNoFacing(text); + return true; + } + + // Parse multiple targets separated by commas + std::vector targetNames; + if (param.find(',') != std::string::npos) + { + std::string targetName; + std::stringstream ss(param); + while (std::getline(ss, targetName, ',')) + targetNames.push_back(targetName); + } + else + targetNames.push_back(param); + + if (targetNames.empty()) + { + std::string text = PlayerbotTextMgr::instance().GetBotTextOrDefault( + "focus_heal_provide_names", + "Please provide one or more player names", + {}); + botAI->TellMasterNoFacing(text); + return false; + } + + if (!bot->GetGroup()) + { + std::string text = PlayerbotTextMgr::instance().GetBotTextOrDefault( + "focus_heal_not_in_group", + "I'm not in a group", + {}); + botAI->TellMasterNoFacing(text); + return false; + } + + for (std::string const& targetName : targetNames) + { + bool const add = targetName.find("+") != std::string::npos; + bool const remove = targetName.find("-") != std::string::npos; + if (!add && !remove) + { + std::string text = PlayerbotTextMgr::instance().GetBotTextOrDefault( + "focus_heal_add_remove_syntax", + "Please specify a + for add or - to remove a target", + {}); + botAI->TellMasterNoFacing(text); + continue; + } + + std::string const playerName = targetName.substr(1); + Player* target = FindGroupPlayerByName(bot, playerName); + if (!target) + { + std::map placeholders; + placeholders["%player_name"] = playerName; + std::string text = PlayerbotTextMgr::instance().GetBotTextOrDefault( + "focus_heal_not_in_group_with", + "I'm not in a group with %player_name", + placeholders); + botAI->TellMasterNoFacing(text); + continue; + } + + ObjectGuid const& targetGuid = target->GetGUID(); + if (add) + { + if (std::find(focusHealTargets.begin(), focusHealTargets.end(), targetGuid) == + focusHealTargets.end()) + focusHealTargets.push_back(targetGuid); + + std::map placeholders; + placeholders["%player_name"] = playerName; + std::string text = PlayerbotTextMgr::instance().GetBotTextOrDefault( + "focus_heal_added", + "Added %player_name to focus heal targets", + placeholders); + botAI->TellMasterNoFacing(text); + } + else + { + focusHealTargets.remove(targetGuid); + std::map placeholders; + placeholders["%player_name"] = playerName; + std::string text = PlayerbotTextMgr::instance().GetBotTextOrDefault( + "focus_heal_removed", + "Removed %player_name from focus heal targets", + placeholders); + botAI->TellMasterNoFacing(text); + } + } + + SET_AI_VALUE(std::list, "focus heal targets", focusHealTargets); + + if (focusHealTargets.empty()) + botAI->ChangeStrategy("-focus heal targets", BOT_STATE_COMBAT); + else + botAI->ChangeStrategy("+focus heal targets", BOT_STATE_COMBAT); + + return true; +} diff --git a/src/Ai/Base/Actions/SetFocusHealTargetsAction.h b/src/Ai/Base/Actions/SetFocusHealTargetsAction.h new file mode 100644 index 00000000000..92d5c0b1983 --- /dev/null +++ b/src/Ai/Base/Actions/SetFocusHealTargetsAction.h @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2016+ AzerothCore , released under GNU AGPL v3 license, you may redistribute it + * and/or modify it under version 3 of the License, or (at your option), any later version. + */ + +#ifndef _PLAYERBOT_SETFOCUSHEALTARGETSACTION_H +#define _PLAYERBOT_SETFOCUSHEALTARGETSACTION_H + +#include "Action.h" + +class PlayerbotAI; + +class SetFocusHealTargetsAction : public Action +{ +public: + SetFocusHealTargetsAction(PlayerbotAI* botAI) : Action(botAI, "focus heal targets") {} + + bool Execute(Event event) override; +}; + +#endif diff --git a/src/Ai/Base/ChatActionContext.h b/src/Ai/Base/ChatActionContext.h index c3a6b3d0219..6f11fb33c64 100644 --- a/src/Ai/Base/ChatActionContext.h +++ b/src/Ai/Base/ChatActionContext.h @@ -37,6 +37,7 @@ #include "LogLevelAction.h" #include "LootStrategyAction.h" #include "LootRollAction.h" +#include "SetFocusHealTargetsAction.h" #include "MailAction.h" #include "NamedObjectContext.h" #include "NewRpgAction.h" @@ -201,6 +202,7 @@ class ChatActionContext : public NamedObjectContext creators["pet attack"] = &ChatActionContext::pet_attack; creators["roll"] = &ChatActionContext::roll_action; creators["wait for attack time"] = &ChatActionContext::wait_for_attack_time; + creators["focus heal targets"] = &ChatActionContext::focus_heal_targets; } private: @@ -314,6 +316,7 @@ class ChatActionContext : public NamedObjectContext static Action* pet_attack(PlayerbotAI* botAI) { return new PetsAction(botAI, "attack"); } static Action* roll_action(PlayerbotAI* botAI) { return new RollAction(botAI); } static Action* wait_for_attack_time(PlayerbotAI* botAI) { return new SetWaitForAttackTimeAction(botAI); } + static Action* focus_heal_targets(PlayerbotAI* botAI) { return new SetFocusHealTargetsAction(botAI); } }; #endif diff --git a/src/Ai/Base/ChatTriggerContext.h b/src/Ai/Base/ChatTriggerContext.h index 6bdfcdc4134..40316bd62a3 100644 --- a/src/Ai/Base/ChatTriggerContext.h +++ b/src/Ai/Base/ChatTriggerContext.h @@ -146,6 +146,7 @@ class ChatTriggerContext : public NamedObjectContext creators["pet attack"] = &ChatTriggerContext::pet_attack; creators["roll"] = &ChatTriggerContext::roll_action; creators["wait for attack time"] = &ChatTriggerContext::wait_for_attack_time; + creators["focus heal"] = &ChatTriggerContext::focus_heal; } private: @@ -271,6 +272,7 @@ class ChatTriggerContext : public NamedObjectContext static Trigger* pet_attack(PlayerbotAI* botAI) { return new ChatCommandTrigger(botAI, "pet attack"); } static Trigger* roll_action(PlayerbotAI* botAI) { return new ChatCommandTrigger(botAI, "roll"); } static Trigger* wait_for_attack_time(PlayerbotAI* botAI) { return new ChatCommandTrigger(botAI, "wait for attack time"); } + static Trigger* focus_heal(PlayerbotAI* botAI) { return new ChatCommandTrigger(botAI, "focus heal"); } }; #endif diff --git a/src/Ai/Base/Strategy/ChatCommandHandlerStrategy.cpp b/src/Ai/Base/Strategy/ChatCommandHandlerStrategy.cpp index e13b57dd10c..77a8d9d0b2d 100644 --- a/src/Ai/Base/Strategy/ChatCommandHandlerStrategy.cpp +++ b/src/Ai/Base/Strategy/ChatCommandHandlerStrategy.cpp @@ -107,6 +107,7 @@ void ChatCommandHandlerStrategy::InitTriggers(std::vector& trigger triggers.push_back(new TriggerNode("pet", { NextAction("pet", relevance) })); triggers.push_back(new TriggerNode("pet attack", { NextAction("pet attack", relevance) })); triggers.push_back(new TriggerNode("roll", { NextAction("roll", relevance) })); + triggers.push_back(new TriggerNode("focus heal", { NextAction("focus heal targets", relevance) })); } ChatCommandHandlerStrategy::ChatCommandHandlerStrategy(PlayerbotAI* botAI) : PassTroughStrategy(botAI) @@ -200,4 +201,5 @@ ChatCommandHandlerStrategy::ChatCommandHandlerStrategy(PlayerbotAI* botAI) : Pas supported.push_back("pet"); supported.push_back("pet attack"); supported.push_back("wait for attack time"); + supported.push_back("focus heal"); } diff --git a/src/Ai/Base/Strategy/FocusTargetStrategy.h b/src/Ai/Base/Strategy/FocusTargetStrategy.h new file mode 100644 index 00000000000..9af35c66653 --- /dev/null +++ b/src/Ai/Base/Strategy/FocusTargetStrategy.h @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2016+ AzerothCore , released under GNU AGPL v3 license, you may redistribute it + * and/or modify it under version 3 of the License, or (at your option), any later version. + */ + +#ifndef _PLAYERBOT_FOCUSTARGETSTRATEGY_H +#define _PLAYERBOT_FOCUSTARGETSTRATEGY_H + +#include "Strategy.h" + +class PlayerbotAI; + +class FocusHealTargetsStrategy : public Strategy +{ +public: + FocusHealTargetsStrategy(PlayerbotAI* botAI) : Strategy(botAI) {} + std::string const getName() override { return "focus heal targets"; } +}; + +#endif diff --git a/src/Ai/Base/StrategyContext.h b/src/Ai/Base/StrategyContext.h index c16573fa071..5386e872bfd 100644 --- a/src/Ai/Base/StrategyContext.h +++ b/src/Ai/Base/StrategyContext.h @@ -19,6 +19,7 @@ #include "DuelStrategy.h" #include "EmoteStrategy.h" #include "FleeStrategy.h" +#include "FocusTargetStrategy.h" #include "FollowMasterStrategy.h" #include "GrindingStrategy.h" #include "GroupStrategy.h" @@ -126,6 +127,7 @@ class StrategyContext : public NamedObjectContext creators["use bobber"] = &StrategyContext::bobber_strategy; creators["master fishing"] = &StrategyContext::master_fishing; creators["wait for attack"] = &StrategyContext::wait_for_attack; + creators["focus heal targets"] = &StrategyContext::focus_heal_targets; } private: @@ -198,6 +200,7 @@ class StrategyContext : public NamedObjectContext static Strategy* bobber_strategy(PlayerbotAI* botAI) { return new UseBobberStrategy(botAI); } static Strategy* master_fishing(PlayerbotAI* botAI) { return new MasterFishingStrategy(botAI); } static Strategy* wait_for_attack(PlayerbotAI* botAI) { return new WaitForAttackStrategy(botAI); } + static Strategy* focus_heal_targets(PlayerbotAI* botAI) { return new FocusHealTargetsStrategy(botAI); } }; class MovementStrategyContext : public NamedObjectContext diff --git a/src/Ai/Base/Value/PartyMemberToHeal.cpp b/src/Ai/Base/Value/PartyMemberToHeal.cpp index b49c1cd4196..9845bc11912 100644 --- a/src/Ai/Base/Value/PartyMemberToHeal.cpp +++ b/src/Ai/Base/Value/PartyMemberToHeal.cpp @@ -38,6 +38,36 @@ Unit* PartyMemberToHeal::Calculate() bool isRaid = bot->GetGroup()->isRaidGroup(); MinValueCalculator calc(100); + // If focus heal targets strategy is active, only heal those targets + if (botAI->HasStrategy("focus heal targets", BOT_STATE_COMBAT)) + { + std::list const focusHealTargets = + AI_VALUE(std::list, "focus heal targets"); + + for (ObjectGuid const& focusHealTarget : focusHealTargets) + { + Player* player = ObjectAccessor::FindPlayer(focusHealTarget); + if (!player || !player->IsInWorld() || !player->IsAlive() || !player->IsInSameGroupWith(bot)) + continue; + + float health = player->GetHealthPct(); + if (isRaid || health < sPlayerbotAIConfig.mediumHealth || + !IsTargetOfSpellCast(player, predicate)) + { + float probeValue = 100.0f; + if (player->GetDistance2d(bot) > sPlayerbotAIConfig.healDistance) + probeValue = health + 30.0f; + else + probeValue = health + player->GetDistance2d(bot) / 10.0f; + + if (probeValue < calc.minValue && Check(player)) + calc.probe(probeValue, player); + } + } + + return (Unit*)calc.param; + } + for (GroupReference* gref = group->GetFirstMember(); gref; gref = gref->next()) { Player* player = gref->GetSource(); @@ -45,17 +75,17 @@ Unit* PartyMemberToHeal::Calculate() continue; if (player && player->IsAlive()) { - uint8 health = player->GetHealthPct(); + float health = player->GetHealthPct(); if (isRaid || health < sPlayerbotAIConfig.mediumHealth || !IsTargetOfSpellCast(player, predicate)) { - uint32 probeValue = 100; + float probeValue = 100.0f; if (player->GetDistance2d(bot) > sPlayerbotAIConfig.healDistance) { - probeValue = health + 30; + probeValue = health + 30.0f; } else { - probeValue = health + player->GetDistance2d(bot) / 10; + probeValue = health + player->GetDistance2d(bot) / 10.0f; } // delay Check player to here for better performance if (probeValue < calc.minValue && Check(player)) @@ -68,10 +98,10 @@ Unit* PartyMemberToHeal::Calculate() Pet* pet = player->GetPet(); if (pet && pet->IsAlive()) { - uint8 health = ((Unit*)pet)->GetHealthPct(); - uint32 probeValue = 100; + float health = ((Unit*)pet)->GetHealthPct(); + float probeValue = 100.0f; if (isRaid || health < sPlayerbotAIConfig.mediumHealth) - probeValue = health + 30; + probeValue = health + 30.0f; // delay Check pet to here for better performance if (probeValue < calc.minValue && Check(pet)) { @@ -82,10 +112,10 @@ Unit* PartyMemberToHeal::Calculate() Unit* charm = player->GetCharm(); if (charm && charm->IsAlive()) { - uint8 health = charm->GetHealthPct(); - uint32 probeValue = 100; + float health = charm->GetHealthPct(); + float probeValue = 100.0f; if (isRaid || health < sPlayerbotAIConfig.mediumHealth) - probeValue = health + 30; + probeValue = health + 30.0f; // delay Check charm to here for better performance if (probeValue < calc.minValue && Check(charm)) { diff --git a/src/Ai/Base/Value/TargetValue.h b/src/Ai/Base/Value/TargetValue.h index e9e6cdea4db..7d766578a53 100644 --- a/src/Ai/Base/Value/TargetValue.h +++ b/src/Ai/Base/Value/TargetValue.h @@ -140,4 +140,11 @@ class BossTargetValue : public TargetValue, public Qualified public: Unit* Calculate(); }; + +class FocusHealTargetValue : public ManualSetValue> +{ +public: + FocusHealTargetValue(PlayerbotAI* botAI) : ManualSetValue>(botAI, {}, "focus heal targets") {} +}; + #endif diff --git a/src/Ai/Base/ValueContext.h b/src/Ai/Base/ValueContext.h index 14cbd185c24..77d25e060ff 100644 --- a/src/Ai/Base/ValueContext.h +++ b/src/Ai/Base/ValueContext.h @@ -241,6 +241,7 @@ class ValueContext : public NamedObjectContext creators["travel target"] = &ValueContext::travel_target; creators["talk target"] = &ValueContext::talk_target; creators["pull target"] = &ValueContext::pull_target; + creators["focus heal targets"] = &ValueContext::focus_heal_targets; creators["group"] = &ValueContext::group; creators["range"] = &ValueContext::range; creators["inside target"] = &ValueContext::inside_target; @@ -497,6 +498,7 @@ class ValueContext : public NamedObjectContext static UntypedValue* next_rpg_action(PlayerbotAI* botAI) { return new NextRpgActionValue(botAI); } static UntypedValue* travel_target(PlayerbotAI* botAI) { return new TravelTargetValue(botAI); } static UntypedValue* pull_target(PlayerbotAI* botAI) { return new PullTargetValue(botAI); } + static UntypedValue* focus_heal_targets(PlayerbotAI* botAI) { return new FocusHealTargetValue(botAI); } static UntypedValue* bg_master(PlayerbotAI* botAI) { return new BgMasterValue(botAI); } static UntypedValue* bg_role(PlayerbotAI* botAI) { return new BgRoleValue(botAI); } From e13aa7d2f633a818da3a7b6664af6caf1ab1d08b Mon Sep 17 00:00:00 2001 From: kadeshar Date: Sat, 11 Apr 2026 07:15:57 +0200 Subject: [PATCH 2/7] IsTank fix for Death Knight (#2296) ## Pull Request Description Death Knights with Frost Presents are recognized by bots as tank Related with: #2290 ## How to Test the Changes 1. Create Death Knight and apply Frost Presence 2. Invite Restoration Druid bot to party 3. Bot should apply "Thorns" on you ## Impact Assessment - Does this change increase per-bot/per-tick processing or risk scaling poorly with thousands of bots? - - [x] No, not at all - - [ ] Minimal impact (**explain below**) - - [ ] Moderate impact (**explain below**) - Does this change modify default bot behavior? - - [ ] No - - [x] Yes (**explain why**) Bots now identify Death Knight with Frost Presence as tank - Does this change add new decision branches or increase maintenance complexity? - - [x] No - - [ ] Yes (**explain below**) ## AI Assistance Was AI assistance used while working on this change? - - [x] No - - [ ] Yes (**explain below**) ## Final Checklist - - [x] Stability is not compromised. - - [x] Performance impact is understood, tested, and acceptable. - - [x] Added logic complexity is justified and explained. - - [x] Any new bot dialogue lines are translated. - - [x] Documentation updated if needed (Conf comments, WiKi commands). ## Notes for Reviewers Example of non Blood Death Knight tank build https://forum.warmane.com/showthread.php?325582 --- src/Bot/PlayerbotAI.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Bot/PlayerbotAI.cpp b/src/Bot/PlayerbotAI.cpp index 1a74b8b2f7d..e9de581dead 100644 --- a/src/Bot/PlayerbotAI.cpp +++ b/src/Bot/PlayerbotAI.cpp @@ -54,6 +54,7 @@ #include "Unit.h" #include "UpdateTime.h" #include "Vehicle.h" +#include "../../../../src/server/scripts/Spells/spell_dk.cpp" const int SPELL_TITAN_GRIP = 49152; @@ -2163,7 +2164,7 @@ bool PlayerbotAI::IsTank(Player* player, bool bySpec) switch (player->getClass()) { case CLASS_DEATH_KNIGHT: - if (tab == DEATH_KNIGHT_TAB_BLOOD) + if (tab == DEATH_KNIGHT_TAB_BLOOD || player->HasAura(SPELL_DK_FROST_PRESENCE)) { return true; } From ae9b76aaa5f5ded7d06947bc26f319be039c2e3b Mon Sep 17 00:00:00 2001 From: Boidl <273966711+Boidl1337@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:16:15 +0200 Subject: [PATCH 3/7] Fix Undead/Draenei bots stuck in starting zones (#2298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Pull Request Description Undead and Draenei bots get stuck in an idle/rest loop in their starting zones because the default NPC scan range (150f) and quest giver filter (80f) are too small and not enough. NPCs fall within range for `WANDER_NPC` to activate (requires >= 3). This adds a configurable area-based override that increases both ranges to 200f only in affected areas. All other zones remain at default values. ## Feature Evaluation - **Minimum logic**: One `std::set::count()` lookup per `Calculate()` call to check if the bot's current area is in the override list. If yes, scan range is 200f instead of 150f. - **Processing cost**: `GetAreaId()` is a cached uint32 read (~1ns). `std::set::count()` on a 2-element set is O(log n) ≈ 1 comparison. Negligible compared to the grid scan itself (~33,000-53,000ns). ## How to Test the Changes 1. Create Undead or Draenei bots (level 1) 2. Observe that they pick up quests and start moving in Deathknell/Ammen Vale 3. Without this fix, they sit in REST status indefinitely 4. Optionally add/remove area IDs via `AiPlayerbot.RpgScanRangeOverrideAreaIds` in playerbots.conf ## Impact Assessment - Does this change increase per-bot/per-tick processing or risk scaling poorly with thousands of bots? - [x] Minimal impact - pmon data (500 bots, extended run): - Default 150f: 0.033ms avg - Global 200f: 0.053ms avg - Area check (this PR): 0.042ms avg - The 0.009ms increase over default is caused by bots currently in starting zones scanning at 200f. Bots outside override areas are unaffected. - Does this change modify default bot behavior? - [x] No - Does this change add new decision branches or increase maintenance complexity? - [x] No - Uses the existing `LoadSet`/`std::set` config pattern already used throughout the codebase. ## AI Assistance - [x] Yes - Used AI to speed up understanding the codebase, locate relevant functions, and compare with the cmangos playerbots implementation. All code was reviewed and tested manually. ## Final Checklist - [x] Stability is not compromised. - [x] Performance impact is understood, tested, and acceptable. - [x] Added logic complexity is justified and explained. - [x] Any new bot dialogue lines are translated. - [x] Documentation updated if needed (Conf comments, WiKi commands). ## Notes for Reviewers The root cause is that `WANDER_NPC` requires `possibleTargets.size() >= 3`, but sparse starting zones have fewer than 3 NPC-flagged units within 150f. Increasing the scan range to 200f brings enough NPCs into range for the status check to pass. The override is configurable via `AiPlayerbot.RpgScanRangeOverrideAreaIds` so server admins can add more areas without code changes. --- src/Ai/Base/Value/PossibleRpgTargetsValue.cpp | 11 ++++++++++- src/Ai/Base/Value/PossibleRpgTargetsValue.h | 2 ++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Ai/Base/Value/PossibleRpgTargetsValue.cpp b/src/Ai/Base/Value/PossibleRpgTargetsValue.cpp index f2b6aef105d..bf9e9d68950 100644 --- a/src/Ai/Base/Value/PossibleRpgTargetsValue.cpp +++ b/src/Ai/Base/Value/PossibleRpgTargetsValue.cpp @@ -13,6 +13,7 @@ #include "ServerFacade.h" #include "SharedDefines.h" #include "NearestGameObjects.h" +#include std::vector PossibleRpgTargetsValue::allowedNpcFlags; @@ -88,8 +89,11 @@ bool PossibleRpgTargetsValue::AcceptUnit(Unit* unit) std::vector PossibleNewRpgTargetsValue::allowedNpcFlags; +// Sparse starting zones where the default scan range is insufficient for WANDER_NPC (requires >= 3 NPCs) +static const std::unordered_set rpgRangeOverrideAreaIds = { 3526 /* Ammen Vale */, 2117 /* Deathknell */ }; + PossibleNewRpgTargetsValue::PossibleNewRpgTargetsValue(PlayerbotAI* botAI, float range) - : NearestUnitsValue(botAI, "possible new rpg targets", range, true) + : NearestUnitsValue(botAI, "possible new rpg targets", range, true), defaultRange(range) { if (allowedNpcFlags.empty()) { @@ -119,6 +123,11 @@ PossibleNewRpgTargetsValue::PossibleNewRpgTargetsValue(PlayerbotAI* botAI, float GuidVector PossibleNewRpgTargetsValue::Calculate() { + if (rpgRangeOverrideAreaIds.count(bot->GetAreaId()) && defaultRange < 200.0f) + range = 200.0f; + else + range = defaultRange; + std::list targets; FindUnits(targets); diff --git a/src/Ai/Base/Value/PossibleRpgTargetsValue.h b/src/Ai/Base/Value/PossibleRpgTargetsValue.h index ae5b596a0d8..00e5f801293 100644 --- a/src/Ai/Base/Value/PossibleRpgTargetsValue.h +++ b/src/Ai/Base/Value/PossibleRpgTargetsValue.h @@ -35,6 +35,8 @@ class PossibleNewRpgTargetsValue : public NearestUnitsValue protected: void FindUnits(std::list& targets) override; bool AcceptUnit(Unit* unit) override; +private: + float defaultRange; }; class PossibleNewRpgGameObjectsValue : public ObjectGuidListCalculatedValue From 74ccc6fbe99cffe4bc1c860ede0997af6eb1e4ee Mon Sep 17 00:00:00 2001 From: kadeshar Date: Sat, 11 Apr 2026 07:16:28 +0200 Subject: [PATCH 4/7] Mage additional stat weight (#2299) ## Pull Request Description Mage before getting Molten Armor dont prioritize Spirit before Intellect ## How to Test the Changes 1. Invite mage which dont have Molten Armor (level < 62) 2. Give him 2 items for same slot one with spirit one with intellect and unequip item on this slot and destroy 3. Bot should equip this with intellect (if other stats are same) ## Impact Assessment - Does this change increase per-bot/per-tick processing or risk scaling poorly with thousands of bots? - - [x] No, not at all - - [ ] Minimal impact (**explain below**) - - [ ] Moderate impact (**explain below**) - Does this change modify default bot behavior? - - [ ] No - - [x] Yes (**explain why**) Mage before getting Molten Armor dont prioritize Spirit before Intellect - Does this change add new decision branches or increase maintenance complexity? - - [x] No - - [ ] Yes (**explain below**) ## AI Assistance Was AI assistance used while working on this change? - - [ ] No - - [x] Yes (**explain below**) To find best spot to change stat weights depending of class and HasSpell. ## Final Checklist - - [x] Stability is not compromised. - - [x] Performance impact is understood, tested, and acceptable. - - [x] Added logic complexity is justified and explained. - - [x] Any new bot dialogue lines are translated. - - [x] Documentation updated if needed (Conf comments, WiKi commands). ## Notes for Reviewers Example stat weights when using Molten Armor (Fire P1 Preset) obraz Example stat weight when using Mage Armor (Fire P1 Preset) obraz --- src/Mgr/Item/StatsWeightCalculator.cpp | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/Mgr/Item/StatsWeightCalculator.cpp b/src/Mgr/Item/StatsWeightCalculator.cpp index 1b3f3dcfcb5..361e4a5f722 100644 --- a/src/Mgr/Item/StatsWeightCalculator.cpp +++ b/src/Mgr/Item/StatsWeightCalculator.cpp @@ -20,6 +20,13 @@ #include "StatsCollector.h" #include "Unit.h" +namespace +{ +constexpr uint32 SPELL_MOLTEN_ARMOR_RANK_1 = 30482; +constexpr uint32 SPELL_MOLTEN_ARMOR_RANK_2 = 43045; +constexpr uint32 SPELL_MOLTEN_ARMOR_RANK_3 = 43046; +} + StatsWeightCalculator::StatsWeightCalculator(Player* player) : player_(player) { if (PlayerbotAI::IsHeal(player)) @@ -454,6 +461,16 @@ void StatsWeightCalculator::GenerateAdditionalWeights(Player* player) if (player->HasAura(51885)) stats_weights_[STATS_TYPE_INTELLECT] += 1.1f; } + else if (cls == CLASS_MAGE) + { + if (!player->HasSpell(SPELL_MOLTEN_ARMOR_RANK_1) + && !player->HasSpell(SPELL_MOLTEN_ARMOR_RANK_2) + && !player->HasSpell(SPELL_MOLTEN_ARMOR_RANK_3)) + { + stats_weights_[STATS_TYPE_INTELLECT] += 0.2f; + stats_weights_[STATS_TYPE_SPIRIT] -= 0.0f; + } + } } void StatsWeightCalculator::CalculateItemSetMod(Player* player, ItemTemplate const* proto) From 53a607e1478b0bbea2c73a6676231621bdb3e9b5 Mon Sep 17 00:00:00 2001 From: Keleborn <22352763+Celandriel@users.noreply.github.com> Date: Fri, 10 Apr 2026 22:16:58 -0700 Subject: [PATCH 5/7] Enable bots to do Outdoor pvp (#2217) ## Pull Request Description Bots will now engage with outdoor pvp targets when in an area with them. I carved this out of the guildrpg system Im working on since it should work just fine as a standalone. Note this requires a core update https://github.com/azerothcore/azerothcore-wotlk/pull/25103 ## Feature Evaluation Its not expensive. the status checks are fairly light and simple. Should be on par with current rpg system actions ## How to Test the Changes You can try to use selfbot to enable this while in EPL, or set the probability of all other rpg actions to 0. ## Impact Assessment - Does this change increase per-bot/per-tick processing or risk scaling poorly with thousands of bots? - [ ] No, not at all - [x] Minimal impact (**explain below**) - [ ] Moderate impact (**explain below**) There is some impact, but should be minimal overall. - Does this change modify default bot behavior? - [ ] No - [x] Yes (**explain why**) It will activate automatically based on default config. - Does this change add new decision branches or increase maintenance complexity? - [ ] No - [x] Yes (**explain below**) ## AI Assistance Was AI assistance used while working on this change? - [ ] No - [x] Yes (**explain below**) Nothing beyond search functionality and autocomplete. ## Final Checklist - [x] Stability is not compromised. - [x] Performance impact is understood, tested, and acceptable. - [x] Added logic complexity is justified and explained. - [x] Documentation updated if needed (Conf comments, WiKi commands). ## Notes for Reviewers --------- Co-authored-by: bash Co-authored-by: Revision Co-authored-by: kadeshar --- conf/playerbots.conf.dist | 2 + src/Ai/Base/ActionContext.h | 3 + src/Ai/Base/TriggerContext.h | 2 + src/Ai/World/Rpg/Action/NewRpgAction.cpp | 11 +- src/Ai/World/Rpg/Action/NewRpgAction.h | 9 +- src/Ai/World/Rpg/Action/NewRpgBaseAction.cpp | 21 ++- src/Ai/World/Rpg/Action/NewRpgBaseAction.h | 2 +- src/Ai/World/Rpg/Action/NewRpgOutdoorPvP.cpp | 128 +++++++++++++++++++ src/Ai/World/Rpg/Action/NewRpgOutdoorPvP.h | 19 +++ src/Ai/World/Rpg/NewRpgInfo.cpp | 17 +++ src/Ai/World/Rpg/NewRpgInfo.h | 9 +- src/Ai/World/Rpg/Strategy/NewRpgStrategy.cpp | 8 ++ src/Bot/RandomPlayerbotMgr.cpp | 4 +- src/PlayerbotAIConfig.cpp | 1 + src/PlayerbotAIConfig.h | 3 +- 15 files changed, 226 insertions(+), 13 deletions(-) create mode 100644 src/Ai/World/Rpg/Action/NewRpgOutdoorPvP.cpp create mode 100644 src/Ai/World/Rpg/Action/NewRpgOutdoorPvP.h diff --git a/conf/playerbots.conf.dist b/conf/playerbots.conf.dist index ccb392e1aef..88920a05041 100644 --- a/conf/playerbots.conf.dist +++ b/conf/playerbots.conf.dist @@ -1038,6 +1038,7 @@ AiPlayerbot.EnableNewRpgStrategy = 1 # DoQuest (Default: 60 Select quest from the quest log and head to the location to attempt completion​) # TravelFlight (Default: 15 Go to the nearest flightmaster and fly to a level-appropriate area) # Rest (Default: 5 Take a break for a while and do nothing) +# OutdoorPvp (Default: 10 Participate in outdoor PvP capture points if already in an outdoor PvP zone) AiPlayerbot.RpgStatusProbWeight.WanderRandom = 15 AiPlayerbot.RpgStatusProbWeight.WanderNpc = 20 AiPlayerbot.RpgStatusProbWeight.GoGrind = 15 @@ -1045,6 +1046,7 @@ AiPlayerbot.RpgStatusProbWeight.GoCamp = 10 AiPlayerbot.RpgStatusProbWeight.DoQuest = 60 AiPlayerbot.RpgStatusProbWeight.TravelFlight = 15 AiPlayerbot.RpgStatusProbWeight.Rest = 5 +AiPlayerbot.RpgStatusProbWeight.OutdoorPvp = 10 # Bots' minimum and maximum level when teleporting in and out of a zone, according to the new RPG strategy # Format: AiPlayerbot.ZoneBracket.zoneID = minLevel,maxLevel diff --git a/src/Ai/Base/ActionContext.h b/src/Ai/Base/ActionContext.h index 41763be90f2..79b5b498581 100644 --- a/src/Ai/Base/ActionContext.h +++ b/src/Ai/Base/ActionContext.h @@ -63,6 +63,7 @@ #include "WorldBuffAction.h" #include "XpGainAction.h" #include "NewRpgAction.h" +#include "NewRpgOutdoorPvP.h" #include "FishingAction.h" #include "CancelChannelAction.h" #include "WaitForAttackAction.h" @@ -265,6 +266,7 @@ class ActionContext : public NamedObjectContext creators["new rpg wander npc"] = &ActionContext::new_rpg_wander_npc; creators["new rpg do quest"] = &ActionContext::new_rpg_do_quest; creators["new rpg travel flight"] = &ActionContext::new_rpg_travel_flight; + creators["new rpg outdoor pvp"] = &ActionContext::new_rpg_outdoor_pvp; creators["wait for attack keep safe distance"] = &ActionContext::wait_for_attack_keep_safe_distance; } @@ -462,6 +464,7 @@ class ActionContext : public NamedObjectContext static Action* new_rpg_wander_npc(PlayerbotAI* ai) { return new NewRpgWanderNpcAction(ai); } static Action* new_rpg_do_quest(PlayerbotAI* ai) { return new NewRpgDoQuestAction(ai); } static Action* new_rpg_travel_flight(PlayerbotAI* ai) { return new NewRpgTravelFlightAction(ai); } + static Action* new_rpg_outdoor_pvp(PlayerbotAI* ai) { return new NewRpgOutdoorPvpAction(ai); } static Action* wait_for_attack_keep_safe_distance(PlayerbotAI* ai) { return new WaitForAttackKeepSafeDistanceAction(ai); } }; diff --git a/src/Ai/Base/TriggerContext.h b/src/Ai/Base/TriggerContext.h index c77df3a31ba..bfdddecf7a3 100644 --- a/src/Ai/Base/TriggerContext.h +++ b/src/Ai/Base/TriggerContext.h @@ -230,6 +230,7 @@ class TriggerContext : public NamedObjectContext creators["wander npc status"] = &TriggerContext::wander_npc_status; creators["do quest status"] = &TriggerContext::do_quest_status; creators["travel flight status"] = &TriggerContext::travel_flight_status; + creators["outdoor pvp status"] = &TriggerContext::outdoor_pvp_status; creators["can self resurrect"] = &TriggerContext::can_self_resurrect; creators["can fish"] = &TriggerContext::can_fish; creators["can use fishing bobber"] = &TriggerContext::can_use_fishing_bobber; @@ -436,6 +437,7 @@ class TriggerContext : public NamedObjectContext static Trigger* wander_npc_status(PlayerbotAI* botAI) { return new NewRpgStatusTrigger(botAI, RPG_WANDER_NPC); } static Trigger* do_quest_status(PlayerbotAI* botAI) { return new NewRpgStatusTrigger(botAI, RPG_DO_QUEST); } static Trigger* travel_flight_status(PlayerbotAI* botAI) { return new NewRpgStatusTrigger(botAI, RPG_TRAVEL_FLIGHT); } + static Trigger* outdoor_pvp_status(PlayerbotAI* botAI) { return new NewRpgStatusTrigger(botAI, RPG_OUTDOOR_PVP); } static Trigger* can_self_resurrect(PlayerbotAI* ai) { return new SelfResurrectTrigger(ai); } static Trigger* can_fish(PlayerbotAI* ai) { return new CanFishTrigger(ai); } static Trigger* can_use_fishing_bobber(PlayerbotAI* ai) { return new CanUseFishingBobberTrigger(ai); } diff --git a/src/Ai/World/Rpg/Action/NewRpgAction.cpp b/src/Ai/World/Rpg/Action/NewRpgAction.cpp index ddd1240da22..ca0ca243360 100644 --- a/src/Ai/World/Rpg/Action/NewRpgAction.cpp +++ b/src/Ai/World/Rpg/Action/NewRpgAction.cpp @@ -62,7 +62,7 @@ bool NewRpgStatusUpdateAction::Execute(Event /*event*/) { case RPG_IDLE: return RandomChangeStatus({RPG_GO_CAMP, RPG_GO_GRIND, RPG_WANDER_RANDOM, RPG_WANDER_NPC, RPG_DO_QUEST, - RPG_TRAVEL_FLIGHT, RPG_REST}); + RPG_TRAVEL_FLIGHT, RPG_REST, RPG_OUTDOOR_PVP}); case RPG_GO_GRIND: { @@ -140,6 +140,15 @@ bool NewRpgStatusUpdateAction::Execute(Event /*event*/) } break; } + case RPG_OUTDOOR_PVP: + { + if (info.HasStatusPersisted(statusOutDoorPvPDuration)) + { + info.ChangeToIdle(); + return true; + } + break; + } default: break; } diff --git a/src/Ai/World/Rpg/Action/NewRpgAction.h b/src/Ai/World/Rpg/Action/NewRpgAction.h index a8cb7a2bcef..83594204b64 100644 --- a/src/Ai/World/Rpg/Action/NewRpgAction.h +++ b/src/Ai/World/Rpg/Action/NewRpgAction.h @@ -47,10 +47,11 @@ class NewRpgStatusUpdateAction : public NewRpgBaseAction protected: // static NewRpgStatusTransitionProb transitionMat; - const int32 statusWanderNpcDuration = 5 * 60 * 1000; - const int32 statusWanderRandomDuration = 5 * 60 * 1000; - const int32 statusRestDuration = 30 * 1000; - const int32 statusDoQuestDuration = 30 * 60 * 1000; + const int32 statusWanderNpcDuration = 5 * MINUTE * IN_MILLISECONDS ; + const int32 statusWanderRandomDuration = 5 * MINUTE * IN_MILLISECONDS ; + const int32 statusRestDuration = 30 * IN_MILLISECONDS ; + const int32 statusDoQuestDuration = 30 * MINUTE * IN_MILLISECONDS ; + const int32 statusOutDoorPvPDuration = HOUR * IN_MILLISECONDS ; }; class NewRpgGoGrindAction : public NewRpgBaseAction diff --git a/src/Ai/World/Rpg/Action/NewRpgBaseAction.cpp b/src/Ai/World/Rpg/Action/NewRpgBaseAction.cpp index 986b2f0f543..092b115387f 100644 --- a/src/Ai/World/Rpg/Action/NewRpgBaseAction.cpp +++ b/src/Ai/World/Rpg/Action/NewRpgBaseAction.cpp @@ -12,6 +12,7 @@ #include "NewRpgStrategy.h" #include "Object.h" #include "ObjectAccessor.h" +#include "OutdoorPvPMgr.h" #include "ObjectDefines.h" #include "ObjectGuid.h" #include "ObjectMgr.h" @@ -222,12 +223,10 @@ bool NewRpgBaseAction::MoveWorldObjectTo(ObjectGuid guid, float distance) return MoveTo(mapId, x, y, z, false, false, false, true); } -bool NewRpgBaseAction::MoveRandomNear(float moveStep, MovementPriority priority) +bool NewRpgBaseAction::MoveRandomNear(float moveStep, MovementPriority priority, WorldObject* center) { if (IsWaitingForLastMove(priority)) - { return false; - } Map* map = bot->GetMap(); const float x = bot->GetPositionX(); @@ -1160,6 +1159,11 @@ bool NewRpgBaseAction::RandomChangeStatus(std::vector candidateSta bot->SetStandState(UNIT_STAND_STATE_SIT); return true; } + case RPG_OUTDOOR_PVP: + { + botAI->rpgInfo.ChangeToOutdoorPvp(); + return true; + } default: { botAI->rpgInfo.ChangeToRest(); @@ -1220,6 +1224,17 @@ bool NewRpgBaseAction::CheckRpgStatusAvailable(NewRpgStatus status) std::vector path; return SelectRandomFlightTaxiNode(flightMaster, path); } + case RPG_OUTDOOR_PVP: + { + if (!bot->IsPvP()) + return false; + uint32 zoneId = bot->GetZoneId(); + if (zoneId == AREA_NAGRAND) + return false; + + OutdoorPvP* outdoorPvP = sOutdoorPvPMgr->GetOutdoorPvPToZoneId(zoneId); + return outdoorPvP != nullptr; + } default: return false; } diff --git a/src/Ai/World/Rpg/Action/NewRpgBaseAction.h b/src/Ai/World/Rpg/Action/NewRpgBaseAction.h index f17891ffc65..eaba7244628 100644 --- a/src/Ai/World/Rpg/Action/NewRpgBaseAction.h +++ b/src/Ai/World/Rpg/Action/NewRpgBaseAction.h @@ -31,7 +31,7 @@ class NewRpgBaseAction : public MovementAction /* MOVEMENT RELATED */ bool MoveFarTo(WorldPosition dest); bool MoveWorldObjectTo(ObjectGuid guid, float distance = INTERACTION_DISTANCE); - bool MoveRandomNear(float moveStep = 50.0f, MovementPriority priority = MovementPriority::MOVEMENT_NORMAL); + bool MoveRandomNear(float moveStep = 50.0f, MovementPriority priority = MovementPriority::MOVEMENT_NORMAL, WorldObject* center = nullptr); bool ForceToWait(uint32 duration, MovementPriority priority = MovementPriority::MOVEMENT_NORMAL); /* QUEST RELATED CHECK */ diff --git a/src/Ai/World/Rpg/Action/NewRpgOutdoorPvP.cpp b/src/Ai/World/Rpg/Action/NewRpgOutdoorPvP.cpp new file mode 100644 index 00000000000..042246cfb4c --- /dev/null +++ b/src/Ai/World/Rpg/Action/NewRpgOutdoorPvP.cpp @@ -0,0 +1,128 @@ +#include "NewRpgOutdoorPvP.h" +#include "OutdoorPvP.h" +#include "OutdoorPvPMgr.h" + +bool NewRpgOutdoorPvpAction::Execute(Event event) +{ + if (!bot->IsPvP()) + { + botAI->rpgInfo.ChangeToIdle(); + return false; + } + if (IsWaitingForLastMove(MovementPriority::MOVEMENT_NORMAL) || !bot->IsOutdoorPvPActive()) + return false; + + uint32 zoneId = bot->GetZoneId(); + OutdoorPvP* outdoorPvP = sOutdoorPvPMgr->GetOutdoorPvPToZoneId(zoneId); + if (!outdoorPvP || zoneId == AREA_NAGRAND) + { + botAI->rpgInfo.ChangeToIdle(); + return false; + } + + OutdoorPvP::OPvPCapturePointMap const& capturePointMap = outdoorPvP->GetCapturePoints(); + + NewRpgInfo& info = botAI->rpgInfo; + auto* dataPtr = std::get_if(&info.data); + if (!dataPtr) + return false; + auto& data = *dataPtr; + // Re-resolve stored spawn ID from the capture point map each tick (avoids dangling pointers) + OPvPCapturePoint* objective = nullptr; + if (data.capturePointSpawnId && !capturePointMap.empty()) + { + auto it = capturePointMap.find(data.capturePointSpawnId); + if (it != capturePointMap.end()) + { + OPvPCapturePoint* capturePoint = it->second; + if (capturePoint && capturePoint->_capturePoint) + { + float threshold = capturePoint->GetMinValue(); + float slider = capturePoint->GetSlider(); + uint8 faction = bot->GetTeamId(); + LOG_DEBUG("playerbots", "[NEW RPG] Bot {} with faction {} is evaluating existing RPG objective {} with threshold {} and slider value {}", bot->GetName(), faction, capturePoint->_capturePoint->GetName(), threshold, slider); + if ((faction == TEAM_HORDE && slider >= -threshold) || + (faction == TEAM_ALLIANCE && slider <= threshold)) + objective = capturePoint; + } + } + if (!objective) + data.capturePointSpawnId = 0; + } + + if (!objective) + { + objective = SelectNewObjective(capturePointMap); + if (!objective) + { + botAI->rpgInfo.ChangeToIdle(); + return true; + } + data.capturePointSpawnId = objective->m_capturePointSpawnId; + LOG_DEBUG("playerbots","[NEW RPG] Bot {} selected OutDoorPvP target capturePointSpawnId {}", bot->GetName(), data.capturePointSpawnId); + } + + GameObject* objectiveGO = objective->_capturePoint; + if (!objectiveGO) + return false; + + if (objectiveGO->GetGoType() != GAMEOBJECT_TYPE_CAPTURE_POINT) + return false; + + float radius = objectiveGO->GetGOInfo()->capturePoint.radius / 2.0f; + if (!objectiveGO->IsWithinDistInMap(bot, radius)) + return MoveFarTo(WorldPosition(objectiveGO)); + + return PatrolCapturePoint(objectiveGO, radius); +} + +OPvPCapturePoint* NewRpgOutdoorPvpAction::SelectNewObjective(OutdoorPvP::OPvPCapturePointMap const& capturePointMap) +{ + OPvPCapturePoint* objective = nullptr; + uint8 faction = bot->GetTeamId(); + std::vector candidateObjectives; + + if (capturePointMap.empty()) + { + botAI->rpgInfo.ChangeToIdle(); + return objective; + } + for (auto const& [guid, point] : capturePointMap) + { + GameObject* capturePointObject = point->_capturePoint; + if (!capturePointObject) + continue; + + float threshold = point->GetMinValue(); + float slider = point->GetSlider(); + if (faction == TEAM_HORDE && slider > -threshold) + candidateObjectives.push_back(point); + else if (faction == TEAM_ALLIANCE && slider < threshold) + candidateObjectives.push_back(point); + } + if (candidateObjectives.empty()) + { + LOG_DEBUG("playerbots", "[New RPG] Bot {} found no valid outdoor PVP objectives to capture", bot->GetName()); + botAI->rpgInfo.ChangeToIdle(); + return objective; + } + int randomIndex = urand(0, candidateObjectives.size() - 1); + objective = candidateObjectives[randomIndex]; + return objective; +} + +bool NewRpgOutdoorPvpAction::PatrolCapturePoint(GameObject* objectiveGO, float radius) +{ + if (IsWaitingForLastMove(MovementPriority::MOVEMENT_NORMAL)) + return false; + + // Randomly pause at the current spot before picking a new patrol point + if (urand(0, 2) == 0) + return ForceToWait(urand(3000, 6000)); + + float patrolRadius = radius * 0.8f; + if (MoveRandomNear(patrolRadius, MovementPriority::MOVEMENT_NORMAL, objectiveGO)) + return true; + + return ForceToWait(urand(3000, 6000)); +} diff --git a/src/Ai/World/Rpg/Action/NewRpgOutdoorPvP.h b/src/Ai/World/Rpg/Action/NewRpgOutdoorPvP.h new file mode 100644 index 00000000000..0487b9891f1 --- /dev/null +++ b/src/Ai/World/Rpg/Action/NewRpgOutdoorPvP.h @@ -0,0 +1,19 @@ +#ifndef PLAYERBOT_NEWRPGOUTDOORPVP_H +#define PLAYERBOT_NEWRPGOUTDOORPVP_H + +#include "NewRpgBaseAction.h" +#include "OutdoorPvP.h" + +class NewRpgOutdoorPvpAction : public NewRpgBaseAction +{ +public: + NewRpgOutdoorPvpAction(PlayerbotAI* botAI) : NewRpgBaseAction(botAI, "new rpg outdoor pvp") {} + + virtual bool Execute(Event event) override; + OPvPCapturePoint* SelectNewObjective(OutdoorPvP::OPvPCapturePointMap const& capturePointMap); + +private: + bool PatrolCapturePoint(GameObject* objectiveGO, float radius); +}; + +#endif diff --git a/src/Ai/World/Rpg/NewRpgInfo.cpp b/src/Ai/World/Rpg/NewRpgInfo.cpp index 4e04ab08674..4935503fc25 100644 --- a/src/Ai/World/Rpg/NewRpgInfo.cpp +++ b/src/Ai/World/Rpg/NewRpgInfo.cpp @@ -47,6 +47,14 @@ void NewRpgInfo::ChangeToTravelFlight(ObjectGuid fromFlightMaster, std::vector) return RPG_REST; if constexpr (std::is_same_v) return RPG_DO_QUEST; if constexpr (std::is_same_v) return RPG_TRAVEL_FLIGHT; + if constexpr (std::is_same_v) return RPG_OUTDOOR_PVP; return RPG_IDLE; }, data); } @@ -153,6 +162,14 @@ std::string NewRpgInfo::ToString() out << "\ntoNode: " << arg.path[arg.path.size() - 1]; out << "\ninFlight: " << arg.inFlight; } + else if constexpr (std::is_same_v) + { + out << "OUTDOOR_PVP"; + if (!arg.capturePointSpawnId) + out << "\nNo capture point assigned."; + else + out << "\ncapturePointSpawnId: " << arg.capturePointSpawnId; + } else out << "UNKNOWN"; }, data); diff --git a/src/Ai/World/Rpg/NewRpgInfo.h b/src/Ai/World/Rpg/NewRpgInfo.h index c2349c14b2f..9e6abdda4c7 100644 --- a/src/Ai/World/Rpg/NewRpgInfo.h +++ b/src/Ai/World/Rpg/NewRpgInfo.h @@ -58,6 +58,11 @@ struct NewRpgInfo { Rest() = default; }; + // RPG_OUTDOOR_PVP + struct OutdoorPvP + { + ObjectGuid::LowType capturePointSpawnId{0}; + }; struct Idle { }; @@ -79,7 +84,8 @@ struct NewRpgInfo WanderRandom, DoQuest, Rest, - TravelFlight + TravelFlight, + OutdoorPvP >; RpgData data; @@ -91,6 +97,7 @@ struct NewRpgInfo void ChangeToWanderRandom(); void ChangeToDoQuest(uint32 questId, const Quest* quest); void ChangeToTravelFlight(ObjectGuid fromFlightMaster, std::vector path); + void ChangeToOutdoorPvp(ObjectGuid::LowType capturePointSpawnId = 0); void ChangeToRest(); void ChangeToIdle(); bool CanChangeTo(NewRpgStatus status); diff --git a/src/Ai/World/Rpg/Strategy/NewRpgStrategy.cpp b/src/Ai/World/Rpg/Strategy/NewRpgStrategy.cpp index f0d0ce5a923..521b15c34ad 100644 --- a/src/Ai/World/Rpg/Strategy/NewRpgStrategy.cpp +++ b/src/Ai/World/Rpg/Strategy/NewRpgStrategy.cpp @@ -65,6 +65,14 @@ void NewRpgStrategy::InitTriggers(std::vector& triggers) } ) ); + triggers.push_back( + new TriggerNode( + "outdoor pvp status", + { + NextAction("new rpg outdoor pvp", 3.0f) + } + ) + ); } void NewRpgStrategy::InitMultipliers(std::vector&) diff --git a/src/Bot/RandomPlayerbotMgr.cpp b/src/Bot/RandomPlayerbotMgr.cpp index a772136e771..ee06c58f899 100644 --- a/src/Bot/RandomPlayerbotMgr.cpp +++ b/src/Bot/RandomPlayerbotMgr.cpp @@ -2861,10 +2861,10 @@ void RandomPlayerbotMgr::PrintStats() LOG_INFO("playerbots", "Bots rpg status:"); LOG_INFO("playerbots", " Idle: {}, Rest: {}, GoGrind: {}, GoCamp: {}, MoveRandom: {}, MoveNpc: {}, DoQuest: {}, " - "TravelFlight: {}", + "TravelFlight: {}, OutdoorPvP: {}", rpgStatusCount[RPG_IDLE], rpgStatusCount[RPG_REST], rpgStatusCount[RPG_GO_GRIND], rpgStatusCount[RPG_GO_CAMP], rpgStatusCount[RPG_WANDER_RANDOM], rpgStatusCount[RPG_WANDER_NPC], - rpgStatusCount[RPG_DO_QUEST], rpgStatusCount[RPG_TRAVEL_FLIGHT]); + rpgStatusCount[RPG_DO_QUEST], rpgStatusCount[RPG_TRAVEL_FLIGHT], rpgStatusCount[RPG_OUTDOOR_PVP]); LOG_INFO("playerbots", "Bots total quests:"); LOG_INFO("playerbots", " Accepted: {}, Rewarded: {}, Dropped: {}", rpgStasticTotal.questAccepted, diff --git a/src/PlayerbotAIConfig.cpp b/src/PlayerbotAIConfig.cpp index f150f7af198..8c8343db2b6 100644 --- a/src/PlayerbotAIConfig.cpp +++ b/src/PlayerbotAIConfig.cpp @@ -652,6 +652,7 @@ bool PlayerbotAIConfig::Initialize() RpgStatusProbWeight[RPG_DO_QUEST] = sConfigMgr->GetOption("AiPlayerbot.RpgStatusProbWeight.DoQuest", 60); RpgStatusProbWeight[RPG_TRAVEL_FLIGHT] = sConfigMgr->GetOption("AiPlayerbot.RpgStatusProbWeight.TravelFlight", 15); RpgStatusProbWeight[RPG_REST] = sConfigMgr->GetOption("AiPlayerbot.RpgStatusProbWeight.Rest", 5); + RpgStatusProbWeight[RPG_OUTDOOR_PVP] = sConfigMgr->GetOption("AiPlayerbot.RpgStatusProbWeight.OutdoorPvp", 10); syncLevelWithPlayers = sConfigMgr->GetOption("AiPlayerbot.SyncLevelWithPlayers", false); randomBotGroupNearby = sConfigMgr->GetOption("AiPlayerbot.RandomBotGroupNearby", false); diff --git a/src/PlayerbotAIConfig.h b/src/PlayerbotAIConfig.h index 7b6c1eb6f57..4e758e79e93 100644 --- a/src/PlayerbotAIConfig.h +++ b/src/PlayerbotAIConfig.h @@ -56,7 +56,8 @@ enum NewRpgStatus : int RPG_TRAVEL_FLIGHT = 6, // Taking a break RPG_REST = 7, - RPG_STATUS_END = 8 + RPG_OUTDOOR_PVP = 8, + RPG_STATUS_END = 9 }; #define MAX_SPECNO 20 From e177901402084ee57224d625d87b87208c510bf2 Mon Sep 17 00:00:00 2001 From: bashermens <31279994+hermensbas@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:18:13 +0200 Subject: [PATCH 6/7] Bug(self-bot): Self-bot should face target when using ranged or melee attack (#2300) ## Pull Request Description Currently when using self-bot and that self-bot is using ranged attacks its not facing the target, this happens because the client doesnt receive the instruction in Player::SendMessageToSet(WorldPacket const* data, bool self); ## How to Test the Changes Create new character with uses range attack as base attack (hunter, mage, druid etc) and observe. Apply patch and observe your self-bot character is now always facing towards the target. ## Impact Assessment - Does this change increase per-bot/per-tick processing or risk scaling poorly with thousands of bots? - - [x] No, not at all - - [ ] Minimal impact (**explain below**) - - [ ] Moderate impact (**explain below**) - Does this change modify default bot behavior? - - [x] No - - [ ] Yes (**explain why**) - Does this change add new decision branches or increase maintenance complexity? - - [x] No - - [ ] Yes (**explain below**) ## AI Assistance Was AI assistance used while working on this change? - - [x] No - - [ ] Yes (**explain below**) ## Final Checklist - - [x] Stability is not compromised. - - [x] Performance impact is understood, tested, and acceptable. - - [x] Added logic complexity is justified and explained. - - [x] Any new bot dialogue lines are translated. - - [x] Documentation updated if needed (Conf comments, WiKi commands). ## Notes for Reviewers --- src/Util/ServerFacade.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Util/ServerFacade.cpp b/src/Util/ServerFacade.cpp index 12a77de6359..1ba1eb5ea5b 100644 --- a/src/Util/ServerFacade.cpp +++ b/src/Util/ServerFacade.cpp @@ -53,8 +53,9 @@ void ServerFacade::SetFacingTo(Player* bot, WorldObject* wo, bool force) bot->SetOrientation(angle); if (!bot->IsRooted()) - bot->SendMovementFlagUpdate(); - // } + // enforce (bool self) true otherwhise when using real-client with self-bot wont + // recieve update; e.g. will not face the target when using (mostly ranged) attack + bot->SendMovementFlagUpdate(true); } Unit* ServerFacade::GetChaseTarget(Unit* target) From 5d7fb9e97d93116a78808d34dea5d9cd056fae26 Mon Sep 17 00:00:00 2001 From: kadeshar Date: Sat, 11 Apr 2026 07:24:38 +0200 Subject: [PATCH 7/7] Druid - lifebloom (#2292) ## Pull Request Description Restoration Druid now use lifebloom when get Clearcasting proc. ## How to Test the Changes 1. Invite to group restoration druid and tank 2. Attack target (for example dummy) 3. When druid heals and get clearcasting should cast lifebloom on tank ## Impact Assessment - Does this change increase per-bot/per-tick processing or risk scaling poorly with thousands of bots? - - [x] No, not at all - - [ ] Minimal impact (**explain below**) - - [ ] Moderate impact (**explain below**) - Does this change modify default bot behavior? - - [ ] No - - [x] Yes (**explain why**) Restoration druid by default use lifebloom on tank when got clearcasting proc. - Does this change add new decision branches or increase maintenance complexity? - - [x] No - - [ ] Yes (**explain below**) ## AI Assistance Was AI assistance used while working on this change? - - [ ] No - - [x] Yes (**explain below**) Copilot CLI to review. ## Final Checklist - - [x] Stability is not compromised. - - [x] Performance impact is understood, tested, and acceptable. - - [x] Added logic complexity is justified and explained. - - [x] Any new bot dialogue lines are translated. - - [x] Documentation updated if needed (Conf comments, WiKi commands). ## Notes for Reviewers obraz obraz --- src/Ai/Class/Druid/Action/DruidActions.cpp | 10 ++++++++++ src/Ai/Class/Druid/Action/DruidActions.h | 8 ++++++++ src/Ai/Class/Druid/DruidAiObjectContext.cpp | 4 ++++ src/Ai/Class/Druid/Strategy/HealDruidStrategy.cpp | 3 +++ src/Ai/Class/Druid/Trigger/DruidTriggers.h | 6 ++++++ 5 files changed, 31 insertions(+) diff --git a/src/Ai/Class/Druid/Action/DruidActions.cpp b/src/Ai/Class/Druid/Action/DruidActions.cpp index d6a950b175f..2eb809480f4 100644 --- a/src/Ai/Class/Druid/Action/DruidActions.cpp +++ b/src/Ai/Class/Druid/Action/DruidActions.cpp @@ -23,6 +23,16 @@ std::vector CastAbolishPoisonOnPartyAction::getAlternatives() CastSpellAction::getPrerequisites()); } +bool CastLifebloomOnMainTankAction::isUseful() +{ + Unit* target = GetTarget(); + if (!target || !target->IsAlive() || !CastSpellAction::isUseful()) + return false; + + Aura* lifebloom = botAI->GetAura("lifebloom", target, true, true); + return !lifebloom || lifebloom->GetStackAmount() < 3 || lifebloom->GetDuration() < 2000; +} + Value* CastEntanglingRootsCcAction::GetTargetValue() { return context->GetValue("cc target", "entangling roots"); diff --git a/src/Ai/Class/Druid/Action/DruidActions.h b/src/Ai/Class/Druid/Action/DruidActions.h index e3e177590d4..016c3dfc433 100644 --- a/src/Ai/Class/Druid/Action/DruidActions.h +++ b/src/Ai/Class/Druid/Action/DruidActions.h @@ -128,6 +128,14 @@ class CastThornsOnMainTankAction : public BuffOnMainTankAction CastThornsOnMainTankAction(PlayerbotAI* botAI) : BuffOnMainTankAction(botAI, "thorns", false) {} }; +class CastLifebloomOnMainTankAction : public BuffOnMainTankAction +{ +public: + CastLifebloomOnMainTankAction(PlayerbotAI* botAI) : BuffOnMainTankAction(botAI, "lifebloom", true) {} + + bool isUseful() override; +}; + class CastOmenOfClarityAction : public CastBuffSpellAction { public: diff --git a/src/Ai/Class/Druid/DruidAiObjectContext.cpp b/src/Ai/Class/Druid/DruidAiObjectContext.cpp index 17b561d19f4..3d9086cffc9 100644 --- a/src/Ai/Class/Druid/DruidAiObjectContext.cpp +++ b/src/Ai/Class/Druid/DruidAiObjectContext.cpp @@ -79,6 +79,7 @@ class DruidTriggerFactoryInternal : public NamedObjectContext DruidTriggerFactoryInternal() { creators["omen of clarity"] = &DruidTriggerFactoryInternal::omen_of_clarity; + creators["clearcasting"] = &DruidTriggerFactoryInternal::clearcasting; creators["thorns"] = &DruidTriggerFactoryInternal::thorns; creators["thorns on party"] = &DruidTriggerFactoryInternal::thorns_on_party; creators["thorns on main tank"] = &DruidTriggerFactoryInternal::thorns_on_main_tank; @@ -117,6 +118,7 @@ class DruidTriggerFactoryInternal : public NamedObjectContext private: static Trigger* natures_swiftness(PlayerbotAI* botAI) { return new NaturesSwiftnessTrigger(botAI); } + static Trigger* clearcasting(PlayerbotAI* botAI) { return new ClearcastingTrigger(botAI); } static Trigger* eclipse_solar(PlayerbotAI* botAI) { return new EclipseSolarTrigger(botAI); } static Trigger* eclipse_lunar(PlayerbotAI* botAI) { return new EclipseLunarTrigger(botAI); } static Trigger* thorns(PlayerbotAI* botAI) { return new ThornsTrigger(botAI); } @@ -209,6 +211,7 @@ class DruidAiObjectContextInternal : public NamedObjectContext creators["thorns"] = &DruidAiObjectContextInternal::thorns; creators["thorns on party"] = &DruidAiObjectContextInternal::thorns_on_party; creators["thorns on main tank"] = &DruidAiObjectContextInternal::thorns_on_main_tank; + creators["lifebloom on main tank"] = &DruidAiObjectContextInternal::lifebloom_on_main_tank; creators["cure poison"] = &DruidAiObjectContextInternal::cure_poison; creators["cure poison on party"] = &DruidAiObjectContextInternal::cure_poison_on_party; creators["abolish poison"] = &DruidAiObjectContextInternal::abolish_poison; @@ -304,6 +307,7 @@ class DruidAiObjectContextInternal : public NamedObjectContext static Action* thorns(PlayerbotAI* botAI) { return new CastThornsAction(botAI); } static Action* thorns_on_party(PlayerbotAI* botAI) { return new CastThornsOnPartyAction(botAI); } static Action* thorns_on_main_tank(PlayerbotAI* botAI) { return new CastThornsOnMainTankAction(botAI); } + static Action* lifebloom_on_main_tank(PlayerbotAI* botAI) { return new CastLifebloomOnMainTankAction(botAI); } static Action* cure_poison(PlayerbotAI* botAI) { return new CastCurePoisonAction(botAI); } static Action* cure_poison_on_party(PlayerbotAI* botAI) { return new CastCurePoisonOnPartyAction(botAI); } static Action* abolish_poison(PlayerbotAI* botAI) { return new CastAbolishPoisonAction(botAI); } diff --git a/src/Ai/Class/Druid/Strategy/HealDruidStrategy.cpp b/src/Ai/Class/Druid/Strategy/HealDruidStrategy.cpp index 7529cbb8ea7..0710e0fdc1e 100644 --- a/src/Ai/Class/Druid/Strategy/HealDruidStrategy.cpp +++ b/src/Ai/Class/Druid/Strategy/HealDruidStrategy.cpp @@ -56,6 +56,9 @@ void HealDruidStrategy::InitTriggers(std::vector& triggers) new TriggerNode("party member critical health", { NextAction("nature's swiftness", ACTION_CRITICAL_HEAL + 4) })); + triggers.push_back(new TriggerNode("clearcasting", + { NextAction("lifebloom on main tank", ACTION_CRITICAL_HEAL - 1) })); + triggers.push_back(new TriggerNode( "group heal setting", { diff --git a/src/Ai/Class/Druid/Trigger/DruidTriggers.h b/src/Ai/Class/Druid/Trigger/DruidTriggers.h index adad0d2a3cf..01daaeba65a 100644 --- a/src/Ai/Class/Druid/Trigger/DruidTriggers.h +++ b/src/Ai/Class/Druid/Trigger/DruidTriggers.h @@ -61,6 +61,12 @@ class OmenOfClarityTrigger : public BuffTrigger OmenOfClarityTrigger(PlayerbotAI* botAI) : BuffTrigger(botAI, "omen of clarity") {} }; +class ClearcastingTrigger : public HasAuraTrigger +{ +public: + ClearcastingTrigger(PlayerbotAI* botAI) : HasAuraTrigger(botAI, "clearcasting") {} +}; + class RakeTrigger : public DebuffTrigger { public: