Files
mod-playerbots/src/Ai/Base/Actions/CheckMountStateAction.cpp
privatecore a0a50204ec Fix action validation checks: isUseful -> isPossible + codestyle fixes and corrections (#2125)
# Pull Request

Fix the incorrect logic flaw when processing actions from different
sources. It should be: `isUseful` -> `isPossible`. The original logic is
based on the Mangosbot code and the impl presented inside
`Engine::DoNextAction`. This should fix all wrong validation orders for
triggers and direct/specific actions.

Code style is based on the AzerothCore style guide + clang-format.

---

## Design Philosophy

We prioritize **stability, performance, and predictability** over
behavioral realism.
Complex player-mimicking logic is intentionally limited due to its
negative impact on scalability, maintainability, and
long-term robustness.

Excessive processing overhead can lead to server hiccups, increased CPU
usage, and degraded performance for all
participants. Because every action and
decision tree is executed **per bot and per trigger**, even small
increases in logic complexity can scale poorly and
negatively affect both players and
world (random) bots. Bots are not expected to behave perfectly, and
perfect simulation of human decision-making is not a
project goal. Increased behavioral
realism often introduces disproportionate cost, reduced predictability,
and significantly higher maintenance overhead.

Every additional branch of logic increases long-term responsibility. All
decision paths must be tested, validated, and
maintained continuously as the system evolves.
If advanced or AI-intensive behavior is introduced, the **default
configuration must remain the lightweight decision
model**. More complex behavior should only be
available as an **explicit opt-in option**, clearly documented as having
a measurable performance cost.

Principles:

- **Stability before intelligence**  
  A stable system is always preferred over a smarter one.

- **Performance is a shared resource**  
  Any increase in bot cost affects all players and all bots.

- **Simple logic scales better than smart logic**  
Predictable behavior under load is more valuable than perfect decisions.

- **Complexity must justify itself**  
  If a feature cannot clearly explain its cost, it should not exist.

- **Defaults must be cheap**  
  Expensive behavior must always be optional and clearly communicated.

- **Bots should look reasonable, not perfect**  
  The goal is believable behavior, not human simulation.

Before submitting, confirm that this change aligns with those
principles.

---

## Feature Evaluation

Please answer the following:

- Describe the **minimum logic** required to achieve the intended
behavior?
- Describe the **cheapest implementation** that produces an acceptable
result?
- Describe the **runtime cost** when this logic executes across many
bots?

---

## How to Test the Changes

- Step-by-step instructions to test the change
- Any required setup (e.g. multiple players, bots, specific
configuration)
- Expected behavior and how to verify it

## Complexity & Impact

Does this change add new decision branches?
- - [x] No
- - [ ] Yes (**explain below**)

Does this change increase per-bot or per-tick processing?
- - [x] No
- - [ ] Yes (**describe and justify impact**)

Could this logic scale poorly under load?
- - [x] No
- - [ ] Yes (**explain why**)
---

## Defaults & Configuration

Does this change modify default bot behavior?
- - [x] No
- - [ ] Yes (**explain why**)

If this introduces more advanced or AI-heavy logic:
- - [ ] Lightweight mode remains the default
- - [ ] More complex behavior is optional and thereby configurable
---

## AI Assistance

Was AI assistance (e.g. ChatGPT or similar tools) used while working on
this change?
- - [x] No
- - [ ] Yes (**explain below**)

If yes, please specify:

- AI tool or model used (e.g. ChatGPT, GPT-4, Claude, etc.)
- Purpose of usage (e.g. brainstorming, refactoring, documentation, code
generation)
- Which parts of the change were influenced or generated
- Whether the result was manually reviewed and adapted

AI assistance is allowed, but all submitted code must be fully
understood, reviewed, and owned by the contributor.
Any AI-influenced changes must be verified against existing CORE and PB
logic. We expect contributors to be honest
about what they do and do not understand.

---

## 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

---

## Notes for Reviewers

Anything that significantly improves realism at the cost of stability or
performance should be carefully discussed
before merging.
2026-02-13 09:24:11 -08:00

493 lines
18 KiB
C++

/*
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>, 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 "CheckMountStateAction.h"
#include "BattleGroundTactics.h"
#include "BattlegroundEY.h"
#include "BattlegroundWS.h"
#include "Event.h"
#include "PlayerbotAI.h"
#include "PlayerbotAIConfig.h"
#include "Playerbots.h"
#include "ServerFacade.h"
#include "SpellAuraEffects.h"
// Define the static map / init bool for caching bot preferred mount data globally
std::unordered_map<uint32, PreferredMountCache> CheckMountStateAction::mountCache;
bool CheckMountStateAction::preferredMountTableChecked = false;
MountData CollectMountData(const Player* bot)
{
MountData data;
for (auto& entry : bot->GetSpellMap())
{
uint32 spellId = entry.first;
SpellInfo const* spellInfo = sSpellMgr->GetSpellInfo(spellId);
if (!spellInfo || spellInfo->Effects[0].ApplyAuraName != SPELL_AURA_MOUNTED)
continue;
if (entry.second->State == PLAYERSPELL_REMOVED || !entry.second->Active || spellInfo->IsPassive())
continue;
int32 effect1 = spellInfo->Effects[1].BasePoints;
int32 effect2 = spellInfo->Effects[2].BasePoints;
int32 speed = std::max(effect1, effect2);
// Update max speed if appropriate.
if (speed > data.maxSpeed)
data.maxSpeed = speed; // In BG, clamp max speed to 99 later; here we just store the maximum found.
// Determine index: flight if either effect has flight aura or specific mount ID.
uint32 index = (spellInfo->Effects[1].ApplyAuraName == SPELL_AURA_MOD_INCREASE_MOUNTED_FLIGHT_SPEED ||
spellInfo->Effects[2].ApplyAuraName == SPELL_AURA_MOD_INCREASE_MOUNTED_FLIGHT_SPEED ||
// Winged Steed of the Ebon Blade
// This mount is meant to autoscale from a 150% flyer
// up to a 280% as you train your flying skill up.
// This incorrectly gets categorised as a ground mount, force this to flyer only.
// TODO: Add other scaling mounts here if they have the same issue, or adjust above
// checks so that they are all correctly detected.
spellInfo->Id == 54729) ? 1 : 0;
data.allSpells[index][speed].push_back(spellId);
}
return data;
}
bool CheckMountStateAction::Execute(Event /*event*/)
{
// Determine if there are no attackers
bool noAttackers = !AI_VALUE2(bool, "combat", "self target") || !AI_VALUE(uint8, "attacker count");
bool enemy = AI_VALUE(Unit*, "enemy player target");
bool dps = AI_VALUE(Unit*, "dps target");
bool shouldDismount = false;
bool shouldMount = false;
Unit* currentTarget = AI_VALUE(Unit*, "current target");
if (currentTarget)
{
float dismountDistance = CalculateDismountDistance();
float mountDistance = CalculateMountDistance();
float combatReach = bot->GetCombatReach() + currentTarget->GetCombatReach();
float distanceToTarget = bot->GetExactDist(currentTarget);
shouldDismount = (distanceToTarget <= dismountDistance + combatReach);
shouldMount = (distanceToTarget > mountDistance + combatReach);
}
else
{
shouldMount = true;
}
// If should dismount, or master (if any) is no longer in travel form, yet bot still is, remove the shapeshifts
if (shouldDismount ||
(masterInShapeshiftForm != FORM_TRAVEL && botInShapeshiftForm == FORM_TRAVEL) ||
(masterInShapeshiftForm != FORM_FLIGHT && botInShapeshiftForm == FORM_FLIGHT && master && !master->IsMounted()) ||
(masterInShapeshiftForm != FORM_FLIGHT_EPIC && botInShapeshiftForm == FORM_FLIGHT_EPIC && master && !master->IsMounted()))
botAI->RemoveShapeshift();
if (shouldDismount && bot->IsMounted())
{
Dismount();
return true;
}
bool inBattleground = bot->InBattleground();
// If there is a master and bot not in BG, follow master's mount state regardless of group leader
if (master && !inBattleground)
{
if (ShouldFollowMasterMountState(master, noAttackers, shouldMount))
return Mount();
else if (ShouldDismountForMaster(master) && bot->IsMounted())
{
Dismount();
return true;
}
return false;
}
// If there is no master or bot in BG
if ((!master || inBattleground) && !bot->IsMounted() &&
noAttackers && shouldMount && !bot->IsInCombat())
return Mount();
if (!bot->IsFlying() && shouldDismount && bot->IsMounted() &&
(enemy || dps || (!noAttackers && bot->IsInCombat())))
{
Dismount();
return true;
}
return false;
}
bool CheckMountStateAction::isUseful()
{
// Not useful when:
if (botAI->IsInVehicle() || bot->isDead() || bot->HasUnitState(UNIT_STATE_IN_FLIGHT) ||
!bot->IsOutdoors() || bot->InArena())
return false;
master = GetMaster();
// Get shapeshift states, only applicable when there's a master
if (master)
{
botInShapeshiftForm = bot->GetShapeshiftForm();
masterInShapeshiftForm = master->GetShapeshiftForm();
}
// Not useful when in combat and not currently mounted / travel formed
if ((bot->IsInCombat() || botAI->GetState() == BOT_STATE_COMBAT) &&
!bot->IsMounted() && botInShapeshiftForm != FORM_TRAVEL && botInShapeshiftForm != FORM_FLIGHT && botInShapeshiftForm != FORM_FLIGHT_EPIC)
return false;
// In addition to checking IsOutdoors, also check whether bot is clipping below floor slightly because that will
// cause bot to falsly indicate they are outdoors. This fixes bug where bot tries to mount indoors (which seems
// to mostly be an issue in tunnels of WSG and AV)
float posZ = bot->GetPositionZ();
float groundLevel = bot->GetMapWaterOrGroundLevel(bot->GetPositionX(), bot->GetPositionY(), posZ);
if (!bot->IsMounted() && !bot->HasWaterWalkAura() && posZ < groundLevel)
return false;
// Not useful when bot does not have mount strat and is not currently mounted
if (!GET_PLAYERBOT_AI(bot)->HasStrategy("mount", BOT_STATE_NON_COMBAT) && !bot->IsMounted())
return false;
// Not useful when level lower than minimum required
if (bot->GetLevel() < sPlayerbotAIConfig.useGroundMountAtMinLevel)
return false;
// Allow mounting while transformed only if the form allows it
if (bot->HasAuraType(SPELL_AURA_TRANSFORM) && bot->IsInDisallowedMountForm())
return false;
// BG Logic
if (bot->InBattleground())
{
// Do not use when carrying BG Flags
if (bot->HasAura(BG_WS_SPELL_WARSONG_FLAG) || bot->HasAura(BG_WS_SPELL_SILVERWING_FLAG) || bot->HasAura(BG_EY_NETHERSTORM_FLAG_SPELL))
return false;
// Only mount if BG starts in less than 30 sec
if (Battleground* bg = bot->GetBattleground())
if (bg->GetStatus() == STATUS_WAIT_JOIN && bg->GetStartDelayTime() > BG_START_DELAY_30S)
return false;
}
return true;
}
bool CheckMountStateAction::Mount()
{
// Remove current Shapeshift if need be
if (botInShapeshiftForm != FORM_TRAVEL &&
botInShapeshiftForm != FORM_FLIGHT &&
botInShapeshiftForm != FORM_FLIGHT_EPIC)
{
botAI->RemoveShapeshift();
botAI->RemoveAura("tree of life");
}
if (TryPreferredMount(master))
return true;
// Get bot mount data
MountData mountData = CollectMountData(bot);
int32 masterMountType = GetMountType(master);
int32 masterSpeed = CalculateMasterMountSpeed(master, mountData);
// Try shapeshift
if (TryForms(master, masterMountType, masterSpeed))
return true;
// Try random mount
auto spellsIt = mountData.allSpells.find(masterMountType);
if (spellsIt != mountData.allSpells.end())
{
auto& spells = spellsIt->second;
if (TryRandomMountFiltered(spells, masterSpeed))
return true;
}
std::vector<Item*> items = AI_VALUE2(std::vector<Item*>, "inventory items", "mount");
if (!items.empty())
return UseItemAuto(*items.begin());
return false;
}
void CheckMountStateAction::Dismount()
{
if (bot->isMoving())
bot->StopMoving();
WorldPacket emptyPacket;
bot->GetSession()->HandleCancelMountAuraOpcode(emptyPacket);
}
bool CheckMountStateAction::TryForms(Player* master, int32 masterMountType, int32 masterSpeed) const
{
if (!master)
return false;
// If both master and bot are in matching forms or master is mounted with corresponding speed, nothing to do
else if
((masterInShapeshiftForm == FORM_TRAVEL && botInShapeshiftForm == FORM_TRAVEL) ||
((masterInShapeshiftForm == FORM_FLIGHT || (masterMountType == 1 && masterSpeed == 149)) && botInShapeshiftForm == FORM_FLIGHT) ||
((masterInShapeshiftForm == FORM_FLIGHT_EPIC || (masterMountType == 1 && masterSpeed == 279)) && botInShapeshiftForm == FORM_FLIGHT_EPIC))
return true;
// Check if master is in Travel Form and bot can do the same
if (botAI->CanCastSpell(SPELL_TRAVEL_FORM, bot, true) &&
masterInShapeshiftForm == FORM_TRAVEL && botInShapeshiftForm != FORM_TRAVEL)
{
botAI->CastSpell(SPELL_TRAVEL_FORM, bot);
return true;
}
// Check if master is in Flight Form or has a flying mount and bot can flight form
if (botAI->CanCastSpell(SPELL_FLIGHT_FORM, bot, true) &&
((masterInShapeshiftForm == FORM_FLIGHT && botInShapeshiftForm != FORM_FLIGHT) ||
(masterMountType == 1 && masterSpeed == 149)))
{
botAI->CastSpell(SPELL_FLIGHT_FORM, bot);
// Compensate speedbuff
bot->SetSpeed(MOVE_RUN, 2.5, true);
return true;
}
// Check if master is in Swift Flight Form or has an epic flying mount and bot can swift flight form
if (botAI->CanCastSpell(SPELL_SWIFT_FLIGHT_FORM, bot, true) &&
((masterInShapeshiftForm == FORM_FLIGHT_EPIC && botInShapeshiftForm != FORM_FLIGHT_EPIC) ||
(masterMountType == 1 && masterSpeed == 279)))
{
botAI->CastSpell(SPELL_SWIFT_FLIGHT_FORM, bot);
// Compensate speedbuff
bot->SetSpeed(MOVE_RUN, 3.8, true);
return true;
}
return false;
}
bool CheckMountStateAction::TryPreferredMount(Player* master) const
{
uint32 botGUID = bot->GetGUID().GetRawValue();
// Build cache (only once)
if (!preferredMountTableChecked)
{
// Verify preferred mounts table existance in the database
QueryResult checkTable = PlayerbotsDatabase.Query(
"SELECT EXISTS(SELECT * FROM information_schema.tables WHERE table_schema = 'acore_playerbots' AND table_name = 'playerbots_preferred_mounts')");
if (checkTable && checkTable->Fetch()[0].Get<uint32>() == 1)
{
preferredMountTableChecked = true;
// Cache all mounts of both types globally, for all entries
QueryResult result = PlayerbotsDatabase.Query("SELECT guid, spellid, type FROM playerbots_preferred_mounts");
if (result)
{
uint32 totalResults = 0;
while (auto row = result->Fetch())
{
uint32 guid = row[0].Get<uint32>();
uint32 spellId = row[1].Get<uint32>();
uint32 mountType = row[2].Get<uint32>();
if (mountType == 0)
mountCache[guid].groundMounts.push_back(spellId);
else if (mountType == 1)
mountCache[guid].flightMounts.push_back(spellId);
totalResults++;
result->NextRow();
}
LOG_INFO("playerbots", "Preferred mounts initialized | Total records: {}", totalResults);
}
}
else // If the SQL table is missing, log an error and return false
{
preferredMountTableChecked = true;
LOG_DEBUG("playerbots", "Preferred mounts SQL table playerbots_preferred_mounts does not exist!");
return false;
}
}
// Pick a random preferred mount from the selection, if available
uint32 chosenMountId = 0;
if (GetMountType(master) == 0 && !mountCache[botGUID].groundMounts.empty())
{
uint32 index = urand(0, mountCache[botGUID].groundMounts.size() - 1);
chosenMountId = mountCache[botGUID].groundMounts[index];
}
else if (GetMountType(master) == 1 && !mountCache[botGUID].flightMounts.empty())
{
uint32 index = urand(0, mountCache[botGUID].flightMounts.size() - 1);
chosenMountId = mountCache[botGUID].flightMounts[index];
}
// No suitable preferred mount found
if (chosenMountId == 0)
return false;
// Check if spell exists
if (!sSpellMgr->GetSpellInfo(chosenMountId))
{
LOG_ERROR("playerbots", "Preferred mount failed: Invalid spell {} | Bot Guid: {}", chosenMountId, botGUID);
return false;
}
// Required here as otherwise bots won't mount in BG's due to them constant moving
if (bot->isMoving())
bot->StopMoving();
// Check if spell can be cast - for now allow all, even if the bot does not have the actual mount
//if (botAI->CanCastSpell(mountId, botAI->GetBot()))
//{
botAI->CastSpell(chosenMountId, botAI->GetBot());
return true;
//}
LOG_DEBUG("playerbots", "Preferred mount failed! | Bot Guid: {}", botGUID);
return false;
}
bool CheckMountStateAction::TryRandomMountFiltered(const std::map<int32, std::vector<uint32>>& spells, int32 masterSpeed) const
{
for (auto const& pair : spells)
{
int32 currentSpeed = pair.first;
if ((masterSpeed > 59 && currentSpeed < 99) || (masterSpeed > 149 && currentSpeed < 279))
continue;
// Pick a random mount from the candidate group.
auto const& ids = pair.second;
if (!ids.empty())
{
// Required here as otherwise bots won't mount in BG's due to them constant moving
if (bot->isMoving())
bot->StopMoving();
uint32 index = urand(0, ids.size() - 1);
if (botAI->CanCastSpell(ids[index], bot))
{
botAI->CastSpell(ids[index], bot);
return true;
}
}
}
return false;
}
float CheckMountStateAction::CalculateDismountDistance() const
{
// Warrior bots should dismount far enough to charge (because it's important for generating some initial rage),
// a real player would be riding toward enemy mashing the charge key but the bots won't cast charge while mounted.
bool isMelee = PlayerbotAI::IsMelee(bot);
float dismountDistance = isMelee ? sPlayerbotAIConfig.meleeDistance + 2.0f : sPlayerbotAIConfig.spellDistance + 2.0f;
return bot->getClass() == CLASS_WARRIOR ? std::max(18.0f, dismountDistance) : dismountDistance;
}
float CheckMountStateAction::CalculateMountDistance() const
{
// Mount distance should be >= 21 regardless of class, because when travelling a distance < 21 it takes longer
// to cast mount-spell than the time saved from the speed increase. At a distance of 21 both approaches take 3
// seconds:
// 21 / 7 = 21 / 14 + 1.5 = 3 (7 = dismounted speed 14 = epic-mount speed 1.5 = mount-spell cast time)
bool isMelee = PlayerbotAI::IsMelee(bot);
float baseDistance = isMelee ? sPlayerbotAIConfig.meleeDistance + 10.0f : sPlayerbotAIConfig.spellDistance + 10.0f;
return std::max(21.0f, baseDistance);
}
bool CheckMountStateAction::ShouldFollowMasterMountState(Player* master, bool noAttackers, bool shouldMount) const
{
bool isMasterMounted = master->IsMounted() || (masterInShapeshiftForm == FORM_FLIGHT ||
masterInShapeshiftForm == FORM_FLIGHT_EPIC ||
masterInShapeshiftForm == FORM_TRAVEL);
return isMasterMounted && !bot->IsMounted() && noAttackers &&
shouldMount && !bot->IsInCombat() && botAI->GetState() != BOT_STATE_COMBAT;
}
bool CheckMountStateAction::ShouldDismountForMaster(Player* master) const
{
bool isMasterMounted = master->IsMounted() || (masterInShapeshiftForm == FORM_FLIGHT ||
masterInShapeshiftForm == FORM_FLIGHT_EPIC ||
masterInShapeshiftForm == FORM_TRAVEL);
return !isMasterMounted && bot->IsMounted();
}
int32 CheckMountStateAction::CalculateMasterMountSpeed(Player* master, const MountData& mountData) const
{
// Check riding skill and level requirements
int32 ridingSkill = bot->GetPureSkillValue(SKILL_RIDING);
int32 botLevel = bot->GetLevel();
if (ridingSkill <= 75 && botLevel < static_cast<int32>(sPlayerbotAIConfig.useFastGroundMountAtMinLevel))
return 59;
// If there is a master and bot not in BG, use master's aura effects.
if (master && !bot->InBattleground())
{
auto auraEffects = master->GetAuraEffectsByType(SPELL_AURA_MOUNTED);
if (!auraEffects.empty())
{
SpellInfo const* masterSpell = auraEffects.front()->GetSpellInfo();
int32 effect1 = masterSpell->Effects[1].BasePoints;
int32 effect2 = masterSpell->Effects[2].BasePoints;
return std::max(effect1, effect2);
}
else if (masterInShapeshiftForm == FORM_FLIGHT_EPIC)
return 279;
else if (masterInShapeshiftForm == FORM_FLIGHT)
return 149;
}
else
{
// Bots on their own.
int32 speed = mountData.maxSpeed;
if (bot->InBattleground() && speed > 99)
return 99;
return speed;
}
return 59;
}
uint32 CheckMountStateAction::GetMountType(Player* master) const
{
if (!master)
return 0;
auto auraEffects = master->GetAuraEffectsByType(SPELL_AURA_MOUNTED);
if (!auraEffects.empty())
{
SpellInfo const* masterSpell = auraEffects.front()->GetSpellInfo();
return (masterSpell->Effects[1].ApplyAuraName == SPELL_AURA_MOD_INCREASE_MOUNTED_FLIGHT_SPEED ||
masterSpell->Effects[2].ApplyAuraName == SPELL_AURA_MOD_INCREASE_MOUNTED_FLIGHT_SPEED) ? 1 : 0;
}
else if (masterInShapeshiftForm == FORM_FLIGHT || masterInShapeshiftForm == FORM_FLIGHT_EPIC)
return 1;
return 0;
}