mod-playerbots/src/Ai/Class/Shaman/Trigger/ShamanTriggers.cpp
Crow 15bf0ab427
Fix Shaman Weapon Enchants & Cure Toxins/Cleanse Spirit (#2234)
<!--
Thank you for contributing to mod-playerbots, please make sure that
you...
1. Submit your PR to the test-staging branch, not master.
2. Read the guidelines below before submitting.
3. Don't delete parts of this template.

DESIGN PHILOSOPHY: We prioritize STABILITY, PERFORMANCE, AND
PREDICTABILITY over behavioral realism.

Every action and decision executes PER BOT AND PER TRIGGER. Small
increases in logic complexity scale
poorly across thousands of bots and negatively affect all. We prioritize
a stable system over a smarter
one. Bots don't need to behave perfectly; believable behavior is the
goal, not human simulation.
Default behavior must be cheap in processing; expensive behavior must be
opt-in.

Before submitting, make sure your changes aligns with these principles.
-->

## Pull Request Description
<!-- Describe what this change does and why it is needed -->

1. I've been having persistent issues with Enhancement Shamans sometimes
applying Rockbiter to both weapons instead of MH Windfury and OH
Flametongue. Rockbiter is the alternative for Flametongue and, through
Flametongue, the alternative for Windfury. But there seemed to be no
obvious reason why a Shaman that had all three abilities would ever use
Rockbiter, which costs more mana than Windfury and Flametongue. Claude's
take on it is that there is instability from ItemForSpellValue related
to its poor way of distinguishing handedness, in addition to it having a
1-second cache, which can cause in some scenarios stale caches for the
action running on each hand back-to-back. I still can't say I fully
understand why the issue exists, but the most straightforward fix that
should prevent this from happening is to just have separate mainhand and
offhand actions for each enchant. So that's what this PR does. The
relevant ActionNodes are now:
 
- The MH-specific chain for Enhancement is WF -> FT -> RB. In practice,
Enhancement should never apply RB because all Shamans under level 10
(when FT is learned) are considered Elemental. The FT -> RB node is just
for Elemental.
- The MH-specific Resto chain (not that Resto can dual-wield) is EL ->
FT -> RB. Againt, FT -> RB is just for Elemental.
- OH for Enhancement is only FT. You cannot be Enhancement before level
10, nor can Enhancement dual-wield before level 40, so no alternative is
needed.

3. I commented out Frostbrand Weapon actions/triggers because the
ability is not included in any strategy. I didn't delete the code
because in the future somebody might want to implement it as I
understand it can be useful for Enhancement Shamans in PvP.
4. Shamans are coded to use "cure poison" and "cure disease", which do
not exist in WotLK, having been combined into Cure Toxins. Wishmaster
has PR #1844 that has been open on this for a long time, but I decided
to correct the abilities here anyway as he was going for a more limited
approach, and I decided to rename all the actions and redo the structure
to rely on ActionNode alternatives, which is pretty much the exact
framework that should be used for this type of situation w/r/t bots.
Now, Shamans prefer Cleanse Spirit (Resto talent, which costs the same
as Cure Toxins and also dispels curses), with an alternative of Cure
Toxins (for poisons and disease only). I tested this and it seems to
work well.
5. I deleted empty ActionNodes.
6. I did some cleanup of formatting and such, but this is not intended
to be a comprehensive refactor.


## Feature Evaluation
<!--
If your PR is very minimal (comment typo, wrong ID reference, etc), and
it is very obvious it will not have
any impact on performance, you may skip these question. If necessary, a
maintainer may ask you for them later.
-->

<!-- Please answer the following: -->
- Describe the **minimum logic** required to achieve the intended
behavior.
- Describe the **processing cost** when this logic executes across many
bots.

I've followed the general intended structure of class strategies with
triggers and actions. The same triggers exist, just different actions
are called based on the trigger that fires, so I don't think there
should be any impact on performance.

## How to Test the Changes
<!--
- Step-by-step instructions to test the change.
- Any required setup (e.g. multiple players, number of bots, specific
configuration).
- Expected behavior and how to verify it.
-->

The best way to get a grasp on if things work is probably to just do
group play for a while with Shamans and make sure they apply the right
enchants and properly cast Cleanse Spirit and Cure Toxins.

## Impact Assessment
<!-- As a generic test, before and after measure of pmon (playerbot pmon
tick) can help you here. -->
- 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**)

Shamans previously did not cure poisons or disease at all, and now they
do with the default "cure" strategy applied.

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

One might say having separate actions per hand for enchants is somewhat
more complex, but ultimately I think it is less confusing to keep those
paths separate.

## Messages to Translate
<!--
Bot messages have to be translatable, but you don't need to do the
translations here. You only need to make sure
the message is in a translatable format, and list in the table the
message_key and the default English message.
Search for GetBotTextOrDefault in the codebase for examples.
-->
Does this change add bot messages to translate?
    - - [x] No
    - - [ ] Yes (**list messages in the table**)

| Message key  | Default message |
| --------------- | ------------------ |
|			 |			      |
|			 |			      |

## AI Assistance
<!--
AI assistance is allowed, but all submitted code must be fully
understood, reviewed, and owned by the contributor.
We expect contributors to be honest about what they do and do not
understand.
-->
Was AI assistance used while working on this change?
    - - [ ] No
    - - [x] Yes (**explain below**)
<!--
If yes, please specify:
- Purpose of usage (e.g. brainstorming, refactoring, documentation, code
generation).
- Which parts of the change were influenced or generated, and whether it
was thoroughly reviewed.
-->

I had Claude try to diagnose the weapon enchant issue. It proposed and
provided the separate MH/OH WF/FT actions. The other things were easy
enough for me to do.

## 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
<!-- Anything else that's helpful to review or test your pull request.
-->

---------

Co-authored-by: Keleborn <22352763+Celandriel@users.noreply.github.com>
Co-authored-by: bash <hermensb@gmail.com>
Co-authored-by: Revision <tkn963@gmail.com>
Co-authored-by: kadeshar <kadeshar@gmail.com>
2026-04-03 13:25:40 -07:00

473 lines
16 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 "ShamanTriggers.h"
#include "Player.h"
#include "PlayerbotAI.h"
#include "ItemTemplate.h"
#include "Playerbots.h"
#include "TotemsShamanStrategy.h"
#include "InstanceScript.h"
#include "Creature.h"
#include "Unit.h"
#include <ctime>
bool MainHandWeaponNoImbueTrigger::IsActive()
{
Item* const itemForSpell = bot->GetItemByPos(INVENTORY_SLOT_BAG_0, EQUIPMENT_SLOT_MAINHAND);
if (!itemForSpell || itemForSpell->GetEnchantmentId(TEMP_ENCHANTMENT_SLOT))
return false;
return true;
}
bool OffHandWeaponNoImbueTrigger::IsActive()
{
Item* const itemForSpell = bot->GetItemByPos(INVENTORY_SLOT_BAG_0, EQUIPMENT_SLOT_OFFHAND);
if (!itemForSpell)
return false;
uint32 invType = itemForSpell->GetTemplate()->InventoryType;
bool allowedType = (invType == INVTYPE_WEAPON) || (invType == INVTYPE_WEAPONOFFHAND);
if (itemForSpell->GetEnchantmentId(TEMP_ENCHANTMENT_SLOT) || !allowedType)
return false;
return true;
}
bool ShockTrigger::IsActive()
{
return SpellTrigger::IsActive() &&
!botAI->HasAura("flame shock", GetTarget(), false, true) &&
!botAI->HasAura("frost shock", GetTarget(), false, true);
}
// Checks if the target's health is above 25%/1500 hp. Returns false if either are true.
// This logic exists to prevent the use of Earth Shock on bosses as an Elemental Shaman.
bool EarthShockExecuteTrigger::IsActive()
{
Unit* target = AI_VALUE(Unit*, "current target");
if (!target)
return false;
float percent = 100.0f * target->GetHealth() / target->GetMaxHealth();
if (percent >= 25.0f)
return false;
if (target->GetHealth() >= 1500)
return false;
return true;
}
bool TotemTrigger::IsActive()
{
return AI_VALUE(uint8, "attacker count") >= attackerCount &&
!AI_VALUE2(bool, "has totem", name) &&
!botAI->HasAura(name, bot);
}
bool WaterWalkingTrigger::IsActive()
{
return BuffTrigger::IsActive() && AI_VALUE2(bool, "swimming", "self target");
}
bool WaterBreathingTrigger::IsActive()
{
return BuffTrigger::IsActive() && AI_VALUE2(bool, "swimming", "self target");
}
bool WaterWalkingOnPartyTrigger::IsActive()
{
return BuffOnPartyTrigger::IsActive() && AI_VALUE2(bool, "swimming", "self target");
}
bool WaterBreathingOnPartyTrigger::IsActive()
{
return BuffOnPartyTrigger::IsActive() && AI_VALUE2(bool, "swimming", "self target");
}
// Checks if Chain Lightning is on Cooldown, and prevents activation if it is.
// This is to ensure that Elemental Mastery is used on Lava Burst (2.0 second cast),
// and not on Chain Lightning (1.5 second cast with talents).
bool ElementalMasteryTrigger::IsActive()
{
return bot->HasSpellCooldown(421);
}
// Checks if Sprit Wolves are out/if Spirit Walk buff is not on the bot/if the cooldown for Spirit Walk is ready.
// There is custom code for the Spirit Walk cooldown (32 seconds), since no working
// code exists in the AC/Playerbots repo for checking if a guardian's spell is on cooldown.
bool SpiritWalkTrigger::IsActive()
{
constexpr uint32 SPIRIT_WOLF = 29264u;
constexpr uint32 SPIRIT_WALK_SPELL_ID = 58875u;
constexpr int COOLDOWN_IN_SECONDS = 32;
time_t now = time(nullptr);
if ((now - lastSpiritWalkTime) < COOLDOWN_IN_SECONDS)
return false;
for (Unit* unit : bot->m_Controlled)
{
Creature* wolf = dynamic_cast<Creature*>(unit);
if (wolf && wolf->GetEntry() == SPIRIT_WOLF && wolf->IsAlive())
{
if (!bot->HasAura(SPIRIT_WALK_SPELL_ID))
{
lastSpiritWalkTime = now;
return true;
}
}
}
return false;
}
// Checks if the bot knows Call of the Elements, and prevents the trigger firing if it doesn't.
// Fires the trigger if at least 2 of the totem slots are empty or out of range.
bool CallOfTheElementsTrigger::IsActive()
{
if (!botAI->CanCastSpell(SPELL_CALL_OF_THE_ELEMENTS, bot, true))
{
return false;
}
int emptyCount = 0;
static const uint8 slots[] = {
SUMMON_SLOT_TOTEM_EARTH, SUMMON_SLOT_TOTEM_FIRE,
SUMMON_SLOT_TOTEM_WATER, SUMMON_SLOT_TOTEM_AIR
};
for (uint8 slot : slots)
{
ObjectGuid guid = bot->m_SummonSlot[slot];
bool possibleEmpty = false;
if (guid.IsEmpty())
{
possibleEmpty = true;
}
else
{
Creature* totem = bot->GetMap()->GetCreature(guid);
if (!totem || totem->GetDistance(bot) > 30.0f)
{
possibleEmpty = true;
}
}
if (!possibleEmpty)
{
continue;
}
if ((slot == SUMMON_SLOT_TOTEM_EARTH && bot->HasSpell(SPELL_STONESKIN_TOTEM_RANK_1)) ||
(slot == SUMMON_SLOT_TOTEM_FIRE && bot->HasSpell(SPELL_SEARING_TOTEM_RANK_1)) ||
(slot == SUMMON_SLOT_TOTEM_WATER && bot->HasSpell(SPELL_HEALING_STREAM_TOTEM_RANK_1)) ||
(slot == SUMMON_SLOT_TOTEM_AIR && bot->HasSpell(SPELL_NATURE_RESISTANCE_TOTEM_RANK_1)))
{
emptyCount++;
}
}
return emptyCount >= 2;
}
// Totemic Recall - Prevents the trigger firing under the following circumstances:
// 1. The bot does not have Totemic Recall (level 30).
// 2. The bot is in an instance and any boss encounter is in progress.
// 3. Any group member or their pet is in combat.
// 4. If Mana Tide Totem or Fire Elemental Totem is active and within 30 yards of the bot.
// 5. Finally, if any totem summon slot is not empty, the trigger will fire.
bool TotemicRecallTrigger::IsActive()
{
if (!bot->HasSpell(SPELL_TOTEMIC_RECALL))
return false;
Map* map = bot->GetMap();
if (map && map->IsDungeon())
{
InstanceScript* instance = ((InstanceMap*)map)->GetInstanceScript();
if (instance)
{
for (uint32 i = 0; i < instance->GetEncounterCount(); ++i)
{
if (instance->GetBossState(i) == IN_PROGRESS)
return false;
}
}
}
Group* group = bot->GetGroup();
if (group)
{
for (GroupReference* ref = group->GetFirstMember(); ref; ref = ref->next())
{
Player* member = ref->GetSource();
if (!member)
continue;
if (member->IsInCombat())
return false;
Pet* pet = member->GetPet();
if (pet && pet->IsInCombat())
return false;
}
}
ObjectGuid guid = bot->m_SummonSlot[SUMMON_SLOT_TOTEM_WATER];
if (!guid.IsEmpty())
{
Creature* totem = bot->GetMap()->GetCreature(guid);
uint32 currentSpell = 0;
if (totem)
{
currentSpell = totem->GetUInt32Value(UNIT_CREATED_BY_SPELL);
}
for (size_t i = 0; i < MANA_TIDE_TOTEM_COUNT; ++i)
{
if (currentSpell == MANA_TIDE_TOTEM[i] && totem && totem->GetDistance(bot) <= 30.0f)
return false;
}
}
guid = bot->m_SummonSlot[SUMMON_SLOT_TOTEM_FIRE];
if (!guid.IsEmpty())
{
Creature* totem = bot->GetMap()->GetCreature(guid);
uint32 currentSpell = 0;
if (totem)
{
currentSpell = totem->GetUInt32Value(UNIT_CREATED_BY_SPELL);
}
for (size_t i = 0; i < FIRE_ELEMENTAL_TOTEM_COUNT; ++i)
{
if (currentSpell == FIRE_ELEMENTAL_TOTEM[i] && totem && totem->GetDistance(bot) <= 30.0f)
return false;
}
}
return !bot->m_SummonSlot[SUMMON_SLOT_TOTEM_EARTH].IsEmpty() ||
!bot->m_SummonSlot[SUMMON_SLOT_TOTEM_FIRE].IsEmpty() ||
!bot->m_SummonSlot[SUMMON_SLOT_TOTEM_WATER].IsEmpty() ||
!bot->m_SummonSlot[SUMMON_SLOT_TOTEM_AIR].IsEmpty();
}
// Find the active totem strategy for this slot, and return the highest-rank spellId the bot knows for it
static uint32 GetRequiredTotemSpellId(PlayerbotAI* botAI, const char* strategies[],
const uint32* spellList[], const size_t spellCounts[], size_t numStrategies)
{
Player* bot = botAI->GetBot();
for (size_t i = 0; i < numStrategies; ++i)
{
if (botAI->HasStrategy(strategies[i], BOT_STATE_COMBAT))
{
// Find the highest-rank spell the bot knows
for (size_t j = 0; j < spellCounts[i]; ++j)
{
if (bot->HasSpell(spellList[i][j]))
{
return spellList[i][j];
}
}
}
}
return 0; // No relevant strategy active, or bot doesn't know any rank
}
bool NoEarthTotemTrigger::IsActive()
{
// Check if the bot has Stoneskin Totem (required level 4) and prevents the trigger firing if it doesn't
if (!bot->HasSpell(SPELL_STONESKIN_TOTEM_RANK_1))
return false;
ObjectGuid guid = bot->m_SummonSlot[SUMMON_SLOT_TOTEM_EARTH];
Creature* totem = nullptr;
uint32 currentSpell = 0;
if (!guid.IsEmpty())
{
totem = bot->GetMap()->GetCreature(guid);
if (totem)
{
currentSpell = totem->GetUInt32Value(UNIT_CREATED_BY_SPELL);
}
}
// Define supported earth totem strategies for this slot:
static const char* names[] = {"strength of earth", "stoneskin", "tremor", "earthbind"};
static const uint32* spells[] = {STRENGTH_OF_EARTH_TOTEM, STONESKIN_TOTEM, TREMOR_TOTEM, EARTHBIND_TOTEM};
static const size_t counts[] = {STRENGTH_OF_EARTH_TOTEM_COUNT, STONESKIN_TOTEM_COUNT, TREMOR_TOTEM_COUNT,
EARTHBIND_TOTEM_COUNT};
uint32 requiredSpell = GetRequiredTotemSpellId(botAI, names, spells, counts, 4);
// EXCEPTION: If Stoneclaw Totem is out and in range, consider the slot "occupied" (do not fire the trigger)
for (size_t i = 0; i < STONECLAW_TOTEM_COUNT; ++i)
{
if (currentSpell == STONECLAW_TOTEM[i] && totem && totem->GetDistance(bot) <= 30.0f)
return false;
}
// If no relevant strategy, only care if the slot is empty or totem is too far away
if (!requiredSpell)
return guid.IsEmpty() || !totem || totem->GetDistance(bot) > 30.0f;
// Fire if slot is empty or wrong totem or totem is too far away
return !currentSpell || currentSpell != requiredSpell || !totem || totem->GetDistance(bot) > 30.0f;
}
bool NoFireTotemTrigger::IsActive()
{
// Check if the bot has Searing Totem (required level 10) and prevents the trigger firing if it doesn't
if (!bot->HasSpell(SPELL_SEARING_TOTEM_RANK_1))
return false;
ObjectGuid guid = bot->m_SummonSlot[SUMMON_SLOT_TOTEM_FIRE];
Creature* totem = nullptr;
uint32 currentSpell = 0;
if (!guid.IsEmpty())
{
totem = bot->GetMap()->GetCreature(guid);
if (totem)
{
currentSpell = totem->GetUInt32Value(UNIT_CREATED_BY_SPELL);
}
}
// Define supported fire totem strategies for this slot:
static const char* names[] = {"searing", "magma", "flametongue", "wrath", "frost resistance"};
static const uint32* spells[] = {SEARING_TOTEM, MAGMA_TOTEM, FLAMETONGUE_TOTEM, TOTEM_OF_WRATH,
FROST_RESISTANCE_TOTEM};
static const size_t counts[] = {SEARING_TOTEM_COUNT, MAGMA_TOTEM_COUNT, FLAMETONGUE_TOTEM_COUNT, TOTEM_OF_WRATH_COUNT,
FROST_RESISTANCE_TOTEM_COUNT};
uint32 requiredSpell = GetRequiredTotemSpellId(botAI, names, spells, counts, 5);
// EXCEPTION: If Fire Elemental is out and in range, consider the slot "occupied" (do not fire the trigger)
for (size_t i = 0; i < FIRE_ELEMENTAL_TOTEM_COUNT; ++i)
{
if (currentSpell == FIRE_ELEMENTAL_TOTEM[i] && totem && totem->GetDistance(bot) <= 30.0f)
return false;
}
// If no relevant strategy, only care if the slot is empty or totem is too far away
if (!requiredSpell)
return guid.IsEmpty() || !totem || totem->GetDistance(bot) > 30.0f;
// Fire if slot is empty or wrong totem or totem is too far away
return !currentSpell || currentSpell != requiredSpell || !totem || totem->GetDistance(bot) > 30.0f;
}
bool NoWaterTotemTrigger::IsActive()
{
// Check if the bot has Healing Stream Totem (required level 20) and prevents the trigger firing if it doesn't
if (!bot->HasSpell(SPELL_HEALING_STREAM_TOTEM_RANK_1))
return false;
ObjectGuid guid = bot->m_SummonSlot[SUMMON_SLOT_TOTEM_WATER];
Creature* totem = nullptr;
uint32 currentSpell = 0;
if (!guid.IsEmpty())
{
totem = bot->GetMap()->GetCreature(guid);
if (totem)
{
currentSpell = totem->GetUInt32Value(UNIT_CREATED_BY_SPELL);
}
}
// Define supported water totem strategies for this slot:
static const char* names[] = {"healing stream", "mana spring", "cleansing", "fire resistance"};
static const uint32* spells[] = {HEALING_STREAM_TOTEM, MANA_SPRING_TOTEM, CLEANSING_TOTEM, FIRE_RESISTANCE_TOTEM};
static const size_t counts[] = {HEALING_STREAM_TOTEM_COUNT, MANA_SPRING_TOTEM_COUNT, CLEANSING_TOTEM_COUNT,
FIRE_RESISTANCE_TOTEM_COUNT};
uint32 requiredSpell = GetRequiredTotemSpellId(botAI, names, spells, counts, 4);
// EXCEPTION: If Mana Tide is out and in range, consider the slot "occupied" (do not fire the trigger)
for (size_t i = 0; i < MANA_TIDE_TOTEM_COUNT; ++i)
{
if (currentSpell == MANA_TIDE_TOTEM[i] && totem && totem->GetDistance(bot) <= 30.0f)
return false;
}
// If no relevant strategy, only care if the slot is empty or totem is too far away
if (!requiredSpell)
{
return guid.IsEmpty() || !totem || totem->GetDistance(bot) > 30.0f;
}
// Fire if slot is empty or wrong totem or totem is too far away
return !currentSpell || currentSpell != requiredSpell || !totem || totem->GetDistance(bot) > 30.0f;
}
bool NoAirTotemTrigger::IsActive()
{
// Check if the bot has Nature Resistance Totem (required level 30) and prevents the trigger firing if it doesn't
if (!bot->HasSpell(SPELL_NATURE_RESISTANCE_TOTEM_RANK_1))
return false;
ObjectGuid guid = bot->m_SummonSlot[SUMMON_SLOT_TOTEM_AIR];
Creature* totem = nullptr;
uint32 currentSpell = 0;
if (!guid.IsEmpty())
{
totem = bot->GetMap()->GetCreature(guid);
if (totem)
{
currentSpell = totem->GetUInt32Value(UNIT_CREATED_BY_SPELL);
}
}
// Define supported air totem strategies for this slot:
static const char* names[] = {"wrath of air", "windfury", "nature resistance", "grounding totem"};
static const uint32* spells[] = {WRATH_OF_AIR_TOTEM, WINDFURY_TOTEM, NATURE_RESISTANCE_TOTEM, GROUNDING_TOTEM};
static const size_t counts[] = {WRATH_OF_AIR_TOTEM_COUNT, WINDFURY_TOTEM_COUNT, NATURE_RESISTANCE_TOTEM_COUNT,
GROUNDING_TOTEM_COUNT};
uint32 requiredSpell = GetRequiredTotemSpellId(botAI, names, spells, counts, 3);
// If no relevant strategy, only care if the slot is empty or totem is too far away
if (!requiredSpell)
{
return guid.IsEmpty() || !totem || totem->GetDistance(bot) > 30.0f;
}
// Fire if slot is empty or wrong totem or totem is too far away
return !currentSpell || currentSpell != requiredSpell || !totem || totem->GetDistance(bot) > 30.0f;
}
bool SetTotemTrigger::IsActive()
{
uint32 highestKnownSpell = 0;
for (size_t i = 0; i < totemSpellIdsCount; ++i)
{
const uint32 spellId = totemSpellIds[i];
if (bot->HasSpell(spellId))
{
highestKnownSpell = spellId;
break;
}
}
if (!highestKnownSpell)
return false;
ActionButton const* button = bot->GetActionButton(actionButtonId);
if (!button || button->GetType() != ACTION_BUTTON_SPELL || button->GetAction() == 0)
return true;
if (button->GetAction() != highestKnownSpell)
return true;
return false;
}