diff --git a/conf/playerbots.conf.dist b/conf/playerbots.conf.dist index a782479550..e3452c5eac 100644 --- a/conf/playerbots.conf.dist +++ b/conf/playerbots.conf.dist @@ -288,9 +288,62 @@ AiPlayerbot.TwoRoundsGearInit = 0 # Default: 0 (disabled) AiPlayerbot.FreeMethodLoot = 0 -# Bots' loot roll level (0 = pass, 1 = greed, 2 = need) -# Default: 1 (greed) -AiPlayerbot.LootRollLevel = 1 +# Bots' loot roll level: +# 0 = always pass +# 1 = always greed (legacy default, bots never roll "need") +# 2 = allow need (recommended with new loot AI) +# +# With the new loot AI, bots can correctly decide when an item is really useful. +# Setting this to 2 lets them use "need" appropriately. Value 1 only does greed, +# which was the old safe default but bypasses the new decision-making. +AiPlayerbot.LootRollLevel = 2 + +# NEED only if the item matches the bot's MAIN spec (stats & armor/weapon type). +# Off-spec upgrades are automatically downgraded to GREED. +# Default: 1 (enabled) +AiPlayerbot.Roll.SmartNeedBySpec = 1 + +# Bind-on-Equip etiquette: GREED by default to keep it tradeable. +# Set to 1 to allow NEED on BoE if it's a clear upgrade. +# Default: 1 (enabled) +AiPlayerbot.Roll.AllowBoENeedIfUpgrade = 1 + +# Bind-on-Use etiquette: GREED by default to keep it tradeable. +# Set to 1 to allow NEED on BoU if it's a clear upgrade. +# Default: 1 (enabled) +AiPlayerbot.Roll.AllowBoUNeedIfUpgrade = 1 + +# If Need-Before-Greed is active and the item is only useful for disenchanting, +# bots will press the "Disenchant" button instead of GREED. +# Default: 1 (enabled) +AiPlayerbot.Roll.UseDEButton = 1 + +# NEED on learnable profession recipes/patterns/books if the bot has the profession. +# Default: 1 (enabled) +AiPlayerbot.Roll.NeedOnProfessionRecipes = 1 + +# Ignore required skill rank when deciding NEED on profession recipes (not recommended for strict etiquette). +# Default: 0 (disabled) +AiPlayerbot.Roll.Recipes.IgnoreSkillRank = 0 + +# Cross-armor rule (cloth/leather/mail on a plate user, etc.): +# Allow NEED only if newScore >= CrossArmorExtraMargin * bestEquippedScore. +# Default: 1.20 (conservative). Use 9.99 to effectively forbid cross-armor NEED. +AiPlayerbot.Roll.CrossArmorExtraMargin = 1.20 + +# Cross-armor policy: when an off-armor (lower tier) item is not primary, should bots PASS instead of GREED? +# false (0): cross-armor -> GREED (default, legacy behavior) +# true (1): cross-armor -> PASS (bots won't roll at all on off-armor) +# Default: 0 (false) +AiPlayerbot.Roll.CrossArmorGreedIsPass = 0 + +# Minimum item level delta to treat a SET TOKEN as a real upgrade (0 means >=). +# Prevents NEED on same-tier duplicates. +# Example: Paladin Prot with chest ilvl 232: +# - “Trophy of the Crusade” (ilvl 245) => NEED allowed. +# - Token ilvl 232 with TokenILevelMargin=0.1 => GREED (no same-tier NEED). +# Default: 0.1 +AiPlayerbot.Roll.TokenILevelMargin = 0.1 # # diff --git a/src/PlayerbotAIConfig.cpp b/src/PlayerbotAIConfig.cpp index db69387e6f..01911024c7 100644 --- a/src/PlayerbotAIConfig.cpp +++ b/src/PlayerbotAIConfig.cpp @@ -610,6 +610,15 @@ bool PlayerbotAIConfig::Initialize() autoPickReward = sConfigMgr->GetOption("AiPlayerbot.AutoPickReward", "yes"); autoEquipUpgradeLoot = sConfigMgr->GetOption("AiPlayerbot.AutoEquipUpgradeLoot", true); equipUpgradeThreshold = sConfigMgr->GetOption("AiPlayerbot.EquipUpgradeThreshold", 1.1f); + allowBoENeedIfUpgrade = sConfigMgr->GetOption("AiPlayerbot.Roll.AllowBoENeedIfUpgrade", true); + allowBoUNeedIfUpgrade = sConfigMgr->GetOption("AiPlayerbot.Roll.AllowBoUNeedIfUpgrade", true); + crossArmorExtraMargin = sConfigMgr->GetOption("AiPlayerbot.Roll.CrossArmorExtraMargin", 1.20f); + crossArmorGreedIsPass = sConfigMgr->GetOption("AiPlayerbot.Roll.CrossArmorGreedIsPass", false); + useDEButton = sConfigMgr->GetOption("AiPlayerbot.Roll.UseDEButton", true); + tokenILevelMargin = sConfigMgr->GetOption("AiPlayerbot.Roll.TokenILevelMargin", 0.10f); + needOnProfessionRecipes = sConfigMgr->GetOption("AiPlayerbot.Roll.NeedOnProfessionRecipes", true); + recipesIgnoreSkillRank = sConfigMgr->GetOption("AiPlayerbot.Roll.Recipes.IgnoreSkillRank", false); + smartNeedBySpec = sConfigMgr->GetOption("AiPlayerbot.Roll.SmartNeedBySpec", true); twoRoundsGearInit = sConfigMgr->GetOption("AiPlayerbot.TwoRoundsGearInit", false); syncQuestWithPlayer = sConfigMgr->GetOption("AiPlayerbot.SyncQuestWithPlayer", true); syncQuestForPlayer = sConfigMgr->GetOption("AiPlayerbot.SyncQuestForPlayer", false); diff --git a/src/PlayerbotAIConfig.h b/src/PlayerbotAIConfig.h index 83a6a20b9a..f266bdda27 100644 --- a/src/PlayerbotAIConfig.h +++ b/src/PlayerbotAIConfig.h @@ -62,7 +62,7 @@ enum NewRpgStatus : int class PlayerbotAIConfig { public: - PlayerbotAIConfig(){}; + PlayerbotAIConfig() {}; static PlayerbotAIConfig* instance() { static PlayerbotAIConfig instance; @@ -343,6 +343,16 @@ class PlayerbotAIConfig std::string autoPickReward; bool autoEquipUpgradeLoot; float equipUpgradeThreshold; + bool allowBoENeedIfUpgrade; // Loot roll fine-tuning + bool allowBoUNeedIfUpgrade; // Allow NEED on BoU if upgrade + float crossArmorExtraMargin; + bool crossArmorGreedIsPass; // If true, off-armor (lower tier) GREED becomes PASS + bool useDEButton; // Allow "Disenchant" on NBG if available + float tokenILevelMargin; // ilvl threshold to consider the token an upgrade + bool smartNeedBySpec; // Intelligent NEED (based on stats/spec) + bool needOnProfessionRecipes; // If true, bots will roll NEED on profession recipes/patterns/books they can use & + // learn. + bool recipesIgnoreSkillRank; // If true, ignore skill rank requirement when rolling on profession recipes bool twoRoundsGearInit; bool syncQuestWithPlayer; bool syncQuestForPlayer; @@ -391,25 +401,11 @@ class PlayerbotAIConfig int32 addClassCommand; int32 addClassAccountPoolSize; int32 maintenanceCommand; - bool altMaintenanceAttunementQs, - altMaintenanceBags, - altMaintenanceAmmo, - altMaintenanceFood, - altMaintenanceReagents, - altMaintenanceConsumables, - altMaintenancePotions, - altMaintenanceTalentTree, - altMaintenancePet, - altMaintenancePetTalents, - altMaintenanceClassSpells, - altMaintenanceAvailableSpells, - altMaintenanceSkills, - altMaintenanceReputation, - altMaintenanceSpecialSpells, - altMaintenanceMounts, - altMaintenanceGlyphs, - altMaintenanceKeyring, - altMaintenanceGemsEnchants; + bool altMaintenanceAttunementQs, altMaintenanceBags, altMaintenanceAmmo, altMaintenanceFood, altMaintenanceReagents, + altMaintenanceConsumables, altMaintenancePotions, altMaintenanceTalentTree, altMaintenancePet, + altMaintenancePetTalents, altMaintenanceClassSpells, altMaintenanceAvailableSpells, altMaintenanceSkills, + altMaintenanceReputation, altMaintenanceSpecialSpells, altMaintenanceMounts, altMaintenanceGlyphs, + altMaintenanceKeyring, altMaintenanceGemsEnchants; int32 autoGearCommand, autoGearCommandAltBots, autoGearQualityLimit, autoGearScoreLimit; uint32 useGroundMountAtMinLevel; diff --git a/src/strategy/actions/LootRollAction.cpp b/src/strategy/actions/LootRollAction.cpp index 912f75fddf..5ce377e903 100644 --- a/src/strategy/actions/LootRollAction.cpp +++ b/src/strategy/actions/LootRollAction.cpp @@ -5,13 +5,1308 @@ #include "LootRollAction.h" +#include +#include +#include +#include +#include +#include +#include + +#include "AiFactory.h" +#include "AiObjectContext.h" #include "Event.h" #include "Group.h" #include "ItemUsageValue.h" +#include "Log.h" #include "LootAction.h" #include "ObjectMgr.h" +#include "Player.h" #include "PlayerbotAIConfig.h" #include "Playerbots.h" +#include "SharedDefines.h" +#include "SkillDiscovery.h" +#include "SpellMgr.h" +#include "StatsWeightCalculator.h" + +// Forward declarations used by helpers defined later in this file +static bool IsPrimaryForSpec(Player* bot, ItemTemplate const* proto); +static bool HasAnyStat(ItemTemplate const* proto, + std::initializer_list mods); + +// Groups the "class + archetype" info in the same place +struct SpecTraits +{ + uint8 cls = 0; + std::string spec; + bool isCaster = false; // everything that affects SP/INT/SPI/MP5 + bool isHealer = false; + bool isTank = false; + bool isPhysical = false; // profiles STR/AGI/AP/ARP/EXP… + // Useful flags for fine rules + bool isDKTank = false; + bool isWarProt = false; + bool isEnhSham = false; + bool isFeralTk = false; + bool isFeralDps = false; + bool isHunter = false; + bool isRogue = false; + bool isWarrior = false; + bool isRetPal = false; + bool isProtPal = false; +}; + +static SpecTraits GetSpecTraits(Player* bot) +{ + SpecTraits t; + if (!bot) + return t; + t.cls = bot->getClass(); + t.spec = AiFactory::GetPlayerSpecName(bot); + + auto specIs = [&](char const* s) { return t.spec == s; }; + + // "Pure caster" classes + const bool pureCasterClass = (t.cls == CLASS_MAGE || t.cls == CLASS_WARLOCK || t.cls == CLASS_PRIEST); + + // Paladin + const bool holyPal = (t.cls == CLASS_PALADIN && specIs("holy")); + const bool protPal = (t.cls == CLASS_PALADIN && (specIs("prot") || specIs("protection"))); + t.isProtPal = protPal; + t.isRetPal = (t.cls == CLASS_PALADIN && !holyPal && !protPal); + // DK + const bool dk = (t.cls == CLASS_DEATH_KNIGHT); + const bool dkBlood = dk && specIs("blood"); + const bool dkFrost = dk && specIs("frost"); + const bool dkUH = dk && (specIs("unholy") || specIs("uh")); + t.isDKTank = (dkBlood || dkFrost) && !dkUH; // tanks “blood/frost” + // Warrior + t.isWarrior = (t.cls == CLASS_WARRIOR); + t.isWarProt = t.isWarrior && (specIs("prot") || specIs("protection")); + // Hunter & Rogue + t.isHunter = (t.cls == CLASS_HUNTER); + t.isRogue = (t.cls == CLASS_ROGUE); + // Shaman + const bool eleSham = (t.cls == CLASS_SHAMAN && specIs("elemental")); + const bool restoSh = (t.cls == CLASS_SHAMAN && (specIs("resto") || specIs("restoration"))); + t.isEnhSham = (t.cls == CLASS_SHAMAN && (specIs("enhance") || specIs("enhancement"))); + // Druid + const bool balance = (t.cls == CLASS_DRUID && specIs("balance")); + const bool restoDr = (t.cls == CLASS_DRUID && (specIs("resto") || specIs("restoration"))); + t.isFeralTk = (t.cls == CLASS_DRUID && (specIs("feraltank") || specIs("bear"))); + t.isFeralDps = (t.cls == CLASS_DRUID && (specIs("feraldps") || specIs("cat") || specIs("kitty"))); + + // Roles + t.isHealer = holyPal || restoSh || restoDr || (t.cls == CLASS_PRIEST && !specIs("shadow")); + t.isTank = protPal || t.isWarProt || t.isFeralTk || t.isDKTank; + t.isCaster = pureCasterClass || holyPal || eleSham || balance || restoDr || restoSh || + (t.cls == CLASS_PRIEST && specIs("shadow")); // Shadow = caster DPS + t.isPhysical = !t.isCaster; + return t; +} + +// Return true if the invType is a "body armor" slot (not jewelry/cape/weapon/shield/relic/holdable) +static bool IsBodyArmorInvType(uint8 invType) +{ + switch (invType) + { + case INVTYPE_HEAD: + case INVTYPE_SHOULDERS: + case INVTYPE_CHEST: + case INVTYPE_ROBE: + case INVTYPE_WAIST: + case INVTYPE_LEGS: + case INVTYPE_FEET: + case INVTYPE_WRISTS: + case INVTYPE_HANDS: + return true; + default: + return false; + } +} + +// Preferred armor subclass (ITEM_SUBCLASS_ARMOR_*) for the bot (WotLK rules) +static uint8 PreferredArmorSubclassFor(Player* bot) +{ + if (!bot) + return ITEM_SUBCLASS_ARMOR_CLOTH; + + uint8 cls = bot->getClass(); + uint32 lvl = bot->GetLevel(); + + // Pure cloth classes + if (cls == CLASS_MAGE || cls == CLASS_PRIEST || cls == CLASS_WARLOCK) + return ITEM_SUBCLASS_ARMOR_CLOTH; + + // Leather forever + if (cls == CLASS_DRUID || cls == CLASS_ROGUE) + return ITEM_SUBCLASS_ARMOR_LEATHER; + + // Hunter / Shaman: <40 leather, >=40 mail + if (cls == CLASS_HUNTER || cls == CLASS_SHAMAN) + return (lvl >= 40u) ? ITEM_SUBCLASS_ARMOR_MAIL : ITEM_SUBCLASS_ARMOR_LEATHER; + + // Warrior / Paladin: <40 mail, >=40 plate + if (cls == CLASS_WARRIOR || cls == CLASS_PALADIN) + return (lvl >= 40u) ? ITEM_SUBCLASS_ARMOR_PLATE : ITEM_SUBCLASS_ARMOR_MAIL; + + // Death Knight: plate from the start + if (cls == CLASS_DEATH_KNIGHT) + return ITEM_SUBCLASS_ARMOR_PLATE; + + return ITEM_SUBCLASS_ARMOR_CLOTH; +} + +// True if the item is a body armor piece of a strictly lower tier than preferred (clothClass != ITEM_CLASS_ARMOR) + return false; + if (!IsBodyArmorInvType(proto->InventoryType)) + return false; // ignore jewelry/capes/etc. + // Shields / relics / holdables are not considered here + if (proto->SubClass == ITEM_SUBCLASS_ARMOR_SHIELD || proto->InventoryType == INVTYPE_RELIC || proto->InventoryType == INVTYPE_HOLDABLE) + return false; + + uint8 preferred = PreferredArmorSubclassFor(bot); + // ITEM_SUBCLASS_ARMOR_* are ordered Cloth(1) < Leather(2) < Mail(3) < Plate(4) on 3.3.5 + return proto->SubClass < preferred; +} + +// Returns true if another bot in the group (with proper armor tier) is likely to NEED this armor piece. +static bool GroupHasPrimaryArmorUserLikelyToNeed(Player* self, ItemTemplate const* proto, int32 randomProperty) +{ + if (!self || !proto) + return false; + + if (proto->Class != ITEM_CLASS_ARMOR || !IsBodyArmorInvType(proto->InventoryType)) + return false; + + Group* group = self->GetGroup(); + if (!group) + return false; + + std::ostringstream out; + if (randomProperty != 0) + out << proto->ItemId << "," << randomProperty; + else + out << proto->ItemId; + + std::string const param = out.str(); + + for (GroupReference* it = group->GetFirstMember(); it; it = it->next()) + { + Player* member = it->GetSource(); + if (!member || member == self || !member->IsInWorld()) + continue; + + PlayerbotAI* memberAI = GET_PLAYERBOT_AI(member); + if (!memberAI) + continue; // ignore real players + + // Do not treat it as "primary" for bots for which this is also cross-armor + if (IsLowerTierArmorForBot(member, proto)) + continue; + + AiObjectContext* ctx = memberAI->GetAiObjectContext(); + if (!ctx) + continue; + + ItemUsage otherUsage = ctx->GetValue("item usage", param)->Get(); + if (otherUsage == ITEM_USAGE_EQUIP || otherUsage == ITEM_USAGE_REPLACE) + { + LOG_DEBUG("playerbots", + "[LootRollDBG] cross-armor: primary armor user {} likely to need item={} \"{}\"", + member->GetName(), proto->ItemId, proto->Name1); + return true; + } + } + + return false; +} + +// Returns true if there is at least one bot in the group for whom this item is: +// - an upgrade (ItemUsage = EQUIP or REPLACE), and +// - a primary-spec item according to IsPrimaryForSpec(). +// +// Used to implement a generic fallback: +// if no such "primary" candidate exists, off-spec upgrades are allowed to keep NEED +// instead of being downgraded to GREED by SmartNeedBySpec. +static bool GroupHasPrimarySpecUpgradeCandidate(Player* self, ItemTemplate const* proto, int32 randomProperty) +{ + if (!self || !proto) + return false; + + Group* group = self->GetGroup(); + if (!group) + return false; + + std::ostringstream out; + if (randomProperty != 0) + out << proto->ItemId << "," << randomProperty; + else + out << proto->ItemId; + + std::string const param = out.str(); + + for (GroupReference* it = group->GetFirstMember(); it; it = it->next()) + { + Player* member = it->GetSource(); + if (!member || member == self || !member->IsInWorld()) + continue; + + PlayerbotAI* memberAI = GET_PLAYERBOT_AI(member); + if (!memberAI) + continue; // ignore real players + + AiObjectContext* ctx = memberAI->GetAiObjectContext(); + if (!ctx) + continue; + + ItemUsage otherUsage = ctx->GetValue("item usage", param)->Get(); + if (otherUsage != ITEM_USAGE_EQUIP && otherUsage != ITEM_USAGE_REPLACE) + continue; + + if (!IsPrimaryForSpec(member, proto)) + continue; + + LOG_DEBUG("playerbots", + "[LootRollDBG] group primary spec upgrade: {} is primary candidate for item={} \"{}\" (usage={})", + member->GetName(), proto->ItemId, proto->Name1, static_cast(otherUsage)); + return true; + } + + LOG_DEBUG("playerbots", + "[LootRollDBG] group primary spec upgrade: no primary candidate for item={} \"{}\"", + proto->ItemId, proto->Name1); + return false; +} + +// Returns true if it is still reasonable for this spec to NEED the item as off-spec, +// when no primary-spec upgrade candidate exists in the group. +static bool IsFallbackNeedReasonableForSpec(Player* bot, ItemTemplate const* proto) +{ + if (!bot || !proto) + return false; + + SpecTraits const traits = GetSpecTraits(bot); + + bool const isJewelry = proto->InventoryType == INVTYPE_TRINKET || proto->InventoryType == INVTYPE_FINGER || + proto->InventoryType == INVTYPE_NECK || proto->InventoryType == INVTYPE_CLOAK; + + bool const isBodyArmor = proto->Class == ITEM_CLASS_ARMOR && IsBodyArmorInvType(proto->InventoryType); + + bool const hasINT = HasAnyStat(proto, {ITEM_MOD_INTELLECT}); + bool const hasSPI = HasAnyStat(proto, {ITEM_MOD_SPIRIT}); + bool const hasMP5 = HasAnyStat(proto, {ITEM_MOD_MANA_REGENERATION}); + bool const hasSP = HasAnyStat(proto, {ITEM_MOD_SPELL_POWER}); + bool const hasSTR = HasAnyStat(proto, {ITEM_MOD_STRENGTH}); + bool const hasAGI = HasAnyStat(proto, {ITEM_MOD_AGILITY}); + bool const hasAP = HasAnyStat(proto, {ITEM_MOD_ATTACK_POWER, ITEM_MOD_RANGED_ATTACK_POWER}); + bool const hasARP = HasAnyStat(proto, {ITEM_MOD_ARMOR_PENETRATION_RATING}); + bool const hasEXP = HasAnyStat(proto, {ITEM_MOD_EXPERTISE_RATING}); + + bool const looksCaster = hasSP || hasSPI || hasMP5 || (hasINT && !hasSTR && !hasAGI && !hasAP); + bool const looksPhysical = hasSTR || hasAGI || hasAP || hasARP || hasEXP; + + // Physical specs: never fallback-NEED pure caster body armor or SP weapons/shields. + if (traits.isPhysical) + { + if (isBodyArmor && looksCaster) + return false; + + if ((proto->Class == ITEM_CLASS_WEAPON || + (proto->Class == ITEM_CLASS_ARMOR && proto->SubClass == ITEM_SUBCLASS_ARMOR_SHIELD)) && + hasSP) + return false; + } + + // Caster/healer specs: never fallback-NEED pure melee body armor or melee-only jewelry. + if (traits.isCaster || traits.isHealer) + { + if (isBodyArmor && looksPhysical && !hasSP && !hasINT) + return false; + + if (isJewelry && looksPhysical && !hasSP && !hasINT) + return false; + } + + // Default: allow fallback NEED for this spec/item combination. + return true; +} + +// Local helper: identifies classic lockboxes the Rogue can pick. +// Keep English-only fallback for name checks. +static bool IsLockbox(ItemTemplate const* proto) +{ + if (!proto) + { + return false; + } + // Primary, data-driven detection + if (proto->LockID) + { + // Most lockboxes are misc/junk and openable in WotLK + if (proto->Class == ITEM_CLASS_MISC) + return true; + } + // English-only fallback on name (align with TokenSlotFromName behavior) + std::string n = proto->Name1; + std::transform(n.begin(), n.end(), n.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return n.find("lockbox") != std::string::npos; +} + +// Local helper: not a class member +static bool HasAnyStat(ItemTemplate const* proto, + std::initializer_list mods) +{ + if (!proto) + { + return false; + } + + for (uint32 i = 0; i < MAX_ITEM_PROTO_STATS; ++i) + { + if (!proto->ItemStat[i].ItemStatValue) + continue; + + ItemModType const t = ItemModType(proto->ItemStat[i].ItemStatType); + for (ItemModType const m : mods) + { + if (t == m) + { + return true; + } + } + } + return false; +} + +// SPECIAL ITEMS PRIORITY PATTERNS // +// "Priority" players for items example: Items with INT+AP (Ret / Hunter / Enh) +static bool GroupHasPreferredIntApUser(Player* self) +{ + if (!self) + { + return false; + } + Group* g = self->GetGroup(); + + if (!g) + { + return false; + } + + for (GroupReference* it = g->GetFirstMember(); it; it = it->next()) + { + Player* p = it->GetSource(); + if (!p || !p->IsInWorld() || p == self) + continue; + if (!GET_PLAYERBOT_AI(p)) + continue; // IGNORE humans: only bots are considered + SpecTraits t = GetSpecTraits(p); + const bool isProtPal = t.isProtPal || (t.cls == CLASS_PALADIN && t.isTank); // fallback + if (t.isRetPal || isProtPal || t.cls == CLASS_HUNTER || t.isEnhSham) + { + LOG_DEBUG("playerbots", "[LootPaternDBG] INT+AP group check: priority looter present -> {} (spec='{}')", + p->GetName(), t.spec); + return true; + } + } + LOG_DEBUG("playerbots", "[LootPaternDBG] INT+AP group check: no loot-priority bot present"); + return false; +} + +// Helper: Is there a priority "likely" to NEED (upgrade/equipable)? +static bool GroupHasPreferredIntApUserLikelyToNeed(Player* self, ItemTemplate const* proto) +{ + Group* g = self ? self->GetGroup() : nullptr; + if (!g || !proto) + { + return false; + } + + for (GroupReference* it = g->GetFirstMember(); it; it = it->next()) + { + Player* p = it->GetSource(); + if (!p || !p->IsInWorld() || p == self) + { + continue; + } + + // ignore all real player + PlayerbotAI* pai = GET_PLAYERBOT_AI(p); + if (!pai) + { + continue; + } + + SpecTraits t = GetSpecTraits(p); + const bool isProtPal = t.isProtPal || (t.cls == CLASS_PALADIN && t.isTank); + const bool isPreferred = t.isRetPal || isProtPal || t.cls == CLASS_HUNTER || t.isEnhSham; + if (!isPreferred) + { + continue; + } + + // Estimate if the priority bot will NEED (plausible upgrade). + AiObjectContext* ctx = pai->GetAiObjectContext(); + std::string param = std::to_string(proto->ItemId); + ItemUsage usage = ctx->GetValue("item usage", param)->Get(); + + LOG_DEBUG("playerbots", + "[LootPaternDBG] INT+AP likely-to-need: {} (spec='{}') usage={} (EQUIP=1 REPLACE=2) itemId={}", + p->GetName(), t.spec, (int)usage, proto->ItemId); + + if (usage == ITEM_USAGE_REPLACE || usage == ITEM_USAGE_EQUIP) + { + LOG_DEBUG("playerbots", "[LootPaternDBG] INT+AP likely-to-need: {} -> TRUE", p->GetName()); + return true; // a priority bot will probably NEED + } + } + LOG_DEBUG("playerbots", "[LootPaternDBG] INT+AP likely-to-need: no loot-priority bot in a position to NEED"); + return false; +} + +// --- Registry "stat patterns" (currently: INT+AP only) +struct StatPattern +{ + const char* name; + bool (*matches)(ItemTemplate const* proto); + bool (*decide)(Player* bot, ItemTemplate const* proto, const SpecTraits& traits, bool& outPrimary); +}; + +// Pattern INT + AP +static bool Match_IntAndAp(ItemTemplate const* proto) +{ + if (!proto) + return false; + const bool hasINT = HasAnyStat(proto, {ITEM_MOD_INTELLECT}); + const bool hasAP = HasAnyStat(proto, {ITEM_MOD_ATTACK_POWER, ITEM_MOD_RANGED_ATTACK_POWER}); + const bool hasSP = HasAnyStat(proto, {ITEM_MOD_SPELL_POWER}); + const bool hasSPI = HasAnyStat(proto, {ITEM_MOD_SPIRIT}); + const bool hasMP5 = HasAnyStat(proto, {ITEM_MOD_MANA_REGENERATION}); + + // Treat as INT+AP hybrid only if it is not clearly a caster/healer item (SP/SPI/MP5). + const bool looksCaster = hasSP || hasSPI || hasMP5; + const bool match = hasINT && hasAP && !looksCaster; + + LOG_DEBUG("playerbots", + "[LootPaternDBG] INT+AP match? {} -> item={} \"{}\" (INT={} AP={} casterStats={})", + match ? "YES" : "NO", proto->ItemId, proto->Name1, + hasINT ? 1 : 0, hasAP ? 1 : 0, looksCaster ? 1 : 0); + + return match; +} + +// Decision: Ret/Hunter/Enh -> primary; non-caster physical -> not primary; casters -> primary only if no priority in group/raid +static bool Decide_IntAndAp(Player* bot, ItemTemplate const* proto, const SpecTraits& traits, bool& outPrimary) +{ + LOG_DEBUG("playerbots", "[LootPaternDBG] patterns: evaluation bot={} item={} \"{}\"", bot->GetName(), proto->ItemId, + proto->Name1); + const bool isProtPal = traits.isProtPal || (traits.cls == CLASS_PALADIN && traits.isTank); // fallback + if (traits.isRetPal || isProtPal || traits.cls == CLASS_HUNTER || traits.isEnhSham) + { + outPrimary = true; + LOG_DEBUG("playerbots", "[LootPaternDBG] INT+AP decide: {} (spec='{}') prioritaire -> primary=1", + bot->GetName(), traits.spec); + return true; + } + if (!traits.isCaster) + { + outPrimary = false; + LOG_DEBUG("playerbots", "[LootPaternDBG] INT+AP decide: {} (spec='{}') physique non-caster -> primary=0", + bot->GetName(), traits.spec); + return true; + } + // Casters: primary if no priority present + if (!GroupHasPreferredIntApUser(bot)) + { + outPrimary = true; + LOG_DEBUG("playerbots", "[LootPaternDBG] INT+AP decide: {} (spec='{}') caster, aucun prioritaire -> primary=1", + bot->GetName(), traits.spec); + return true; + } + // or if there are no "likely NEED" priorities + outPrimary = !GroupHasPreferredIntApUserLikelyToNeed(bot, proto); + LOG_DEBUG("playerbots", "[LootPaternDBG] INT+AP decide: {} (spec='{}') caster, prioritaires présents -> primary={}", + bot->GetName(), traits.spec, (int)outPrimary); + return true; +} + +// List of active patterns (only INT+AP for now) +static const std::array kStatPatterns = {{ + {"INT+AP", &Match_IntAndAp, &Decide_IntAndAp}, +}}; + +static bool ApplyStatPatternsForPrimary(Player* bot, ItemTemplate const* proto, const SpecTraits& traits, + bool& outPrimary) +{ + for (auto const& p : kStatPatterns) + { + if (p.matches(proto)) + { + if (p.decide(bot, proto, traits, outPrimary)) + { + LOG_DEBUG("playerbots", "[LootPaternDBG] pattern={} primary={} bot={} item={} \"{}\"", p.name, + (int)outPrimary, bot->GetName(), proto->ItemId, proto->Name1); + return true; + } + } + } + LOG_DEBUG("playerbots", "[LootPaternDBG] patterns: no applicable pattern bot={} item={} \"{}\"", bot->GetName(), + proto->ItemId, proto->Name1); + return false; +} +// END SPECIAL STUFF // + +// Encode "random enchant" parameter for CalculateRollVote / ItemUsage +// >0 => randomPropertyId, <0 => randomSuffixId, 0 => none +static inline int32 EncodeRandomEnchantParam(uint32 randomPropertyId, uint32 randomSuffix) +{ + if (randomPropertyId) + { + return static_cast(randomPropertyId); + } + if (randomSuffix) + { + return -static_cast(randomSuffix); + } + + return 0; +} + +// Professions helpers Returns true if the item is a Recipe/Pattern/Book (ITEM_CLASS_RECIPE) +static inline bool IsRecipeItem(ItemTemplate const* proto) { return proto && proto->Class == ITEM_CLASS_RECIPE; } + +// Try to detect the spell taught by a recipe and whether the bot already knows it. If we can’t resolve the taught spell +// reliably, we fall back to "has profession + skill rank OK". +static bool BotAlreadyKnowsRecipeSpell(Player* bot, ItemTemplate const* proto) +{ + if (!bot || !proto) + return false; + + // Many recipes have a single spell that "teaches" another spell (learned spell in EffectTriggerSpell). + for (int i = 0; i < MAX_ITEM_PROTO_SPELLS; ++i) + { + uint32 teach = proto->Spells[i].SpellId; + if (!teach) + continue; + SpellInfo const* si = sSpellMgr->GetSpellInfo(teach); + if (!si) + continue; + for (int eff = 0; eff < MAX_SPELL_EFFECTS; ++eff) + { + if (si->Effects[eff].Effect == SPELL_EFFECT_LEARN_SPELL) + { + uint32 learned = si->Effects[eff].TriggerSpell; + if (learned && bot->HasSpell(learned)) + return true; // already knows the taught spell + } + } + } + return false; +} + +// Special-case: Book of Glyph Mastery (can own several; do not downgrade NEED on duplicates) +static bool IsGlyphMasteryBook(ItemTemplate const* proto) +{ + if (!proto) + { + return false; + } + + // 1) Type-safety: it must be a recipe book + if (proto->Class != ITEM_CLASS_RECIPE || proto->SubClass != ITEM_SUBCLASS_BOOK) + { + return false; + } + + // 2) Primary signal: the on-use spell of the book on WotLK DBs + // (Spell 64323: "Book of Glyph Mastery"). Use a named constant to avoid magic numbers. + constexpr uint32 SPELL_BOOK_OF_GLYPH_MASTERY = 64323; // WotLK 3.3.5a + for (int i = 0; i < MAX_ITEM_PROTO_SPELLS; ++i) + { + if (proto->Spells[i].SpellId == SPELL_BOOK_OF_GLYPH_MASTERY) + return true; + } + + // 3) Fallback: Inscription recipe book + localized name tokens (covers DB forks/locales) + if (proto->RequiredSkill == SKILL_INSCRIPTION) + { + std::string n = proto->Name1; + std::transform(n.begin(), n.end(), n.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (n.find("glyph mastery") != std::string::npos || n.find("book of glyph mastery") != std::string::npos) + return true; + } + + return false; +} + +// Pretty helper for RollVote name in logs +static inline char const* VoteTxt(RollVote v) +{ + switch (v) + { + case NEED: + return "NEED"; + case GREED: + return "GREED"; + case PASS: + return "PASS"; + case DISENCHANT: + return "DISENCHANT"; + default: + return "UNKNOWN"; + } +} + +// Centralised debug dump for recipe decisions +static void DebugRecipeRoll(Player* bot, ItemTemplate const* proto, ItemUsage usage, bool recipeChecked, + bool recipeUseful, bool recipeKnown, uint32 reqSkill, uint32 reqRank, uint32 botRank, + RollVote before, RollVote after) +{ + LOG_DEBUG("playerbots", + "[LootPaternDBG] {} JC:{} item:{} \"{}\" class={} sub={} bond={} usage={} " + "recipeChecked={} useful={} known={} reqSkill={} reqRank={} botRank={} vote:{} -> {} dupCount={}", + bot->GetName(), bot->GetSkillValue(SKILL_JEWELCRAFTING), proto->ItemId, proto->Name1, proto->Class, + proto->SubClass, proto->Bonding, (int)usage, recipeChecked, recipeUseful, recipeKnown, reqSkill, reqRank, + botRank, VoteTxt(before), VoteTxt(after), bot->GetItemCount(proto->ItemId, true)); +} + +// Maps a RECIPE subclass & item metadata to the SkillLine needed (when RequiredSkill is not set). In DBs, recipes +// normally have RequiredSkill filled; we keep this as a fallback. +static uint32 GuessRecipeSkill(ItemTemplate const* proto) +{ + if (!proto) + return 0; + // If the core DB is sane, this is set and we can just return it in the caller. + // Fallback heuristic on SubClass (books used by professions) + switch (proto->SubClass) + { + case ITEM_SUBCLASS_BOOK: // e.g. Book of Glyph Mastery + // If the name hints glyphs, assume Inscription + { + std::string n = proto->Name1; + std::transform(n.begin(), n.end(), n.begin(), [](unsigned char c) { return std::tolower(c); }); + if (n.find("glyph") != std::string::npos) + return SKILL_INSCRIPTION; + } + break; + case ITEM_SUBCLASS_LEATHERWORKING_PATTERN: + return SKILL_LEATHERWORKING; + case ITEM_SUBCLASS_TAILORING_PATTERN: + return SKILL_TAILORING; + case ITEM_SUBCLASS_ENGINEERING_SCHEMATIC: + return SKILL_ENGINEERING; + case ITEM_SUBCLASS_BLACKSMITHING: + return SKILL_BLACKSMITHING; + case ITEM_SUBCLASS_COOKING_RECIPE: + return SKILL_COOKING; + case ITEM_SUBCLASS_ALCHEMY_RECIPE: + return SKILL_ALCHEMY; + case ITEM_SUBCLASS_FIRST_AID_MANUAL: + return SKILL_FIRST_AID; + case ITEM_SUBCLASS_ENCHANTING_FORMULA: + return SKILL_ENCHANTING; + case ITEM_SUBCLASS_JEWELCRAFTING_RECIPE: + return SKILL_JEWELCRAFTING; + default: + break; + } + return 0; +} + +// Returns true if this recipe/pattern/book is useful for one of the bot's professions and not already known. +static bool IsProfessionRecipeUsefulForBot(Player* bot, ItemTemplate const* proto) +{ + if (!bot || !IsRecipeItem(proto)) + { + return false; + } + + // Primary path: DB usually sets RequiredSkill/RequiredSkillRank on recipe items. + uint32 reqSkill = proto->RequiredSkill; + uint32 reqRank = proto->RequiredSkillRank; + + if (!reqSkill) + { + reqSkill = GuessRecipeSkill(proto); + } + + if (!reqSkill) + { + return false; // unknown profession, be conservative + } + + // Bot must have the profession (or secondary skill like Cooking/First Aid) + if (!bot->HasSkill(reqSkill)) + { + return false; + } + + // Required rank check (can be disabled by config) — flatten nested if + if (!sPlayerbotAIConfig->recipesIgnoreSkillRank && reqRank && bot->GetSkillValue(reqSkill) < reqRank) + { + return false; + } + + // Avoid NEED if the taught spell is already known + if (BotAlreadyKnowsRecipeSpell(bot, proto)) + { + return false; + } + + return true; +} + +// Weapon/shield/relic whitelist per class. +// Returns false when the item is a WEAPON / SHIELD / RELIC the class should NOT use. +static bool IsWeaponOrShieldOrRelicAllowedForClass(SpecTraits const& traits, ItemTemplate const* proto) +{ + if (!proto) + { + return true; // non-weapon items handled elsewhere + } + + // Shields (Armor + Shield): Paladin / Warrior / Shaman + if ((proto->Class == ITEM_CLASS_ARMOR && proto->SubClass == ITEM_SUBCLASS_ARMOR_SHIELD) || + proto->InventoryType == INVTYPE_SHIELD) + { + return traits.cls == CLASS_PALADIN || traits.cls == CLASS_WARRIOR || traits.cls == CLASS_SHAMAN; + } + + // Relics (Idol/Totem/Sigil/Libram) + if (proto->InventoryType == INVTYPE_RELIC) + { + // DK (Sigil), Druid (Idol), Paladin (Libram), Shaman (Totem) + return traits.cls == CLASS_DEATH_KNIGHT || traits.cls == CLASS_DRUID || traits.cls == CLASS_PALADIN || + traits.cls == CLASS_SHAMAN; + } + + // Not a weapon: nothing to filter here + if (proto->Class != ITEM_CLASS_WEAPON) + return true; + + switch (proto->SubClass) + { + // Axes + case ITEM_SUBCLASS_WEAPON_AXE: + case ITEM_SUBCLASS_WEAPON_AXE2: + // 1H axes allowed for Rogue; 2H axes not (but same SubClass enum, handled by InventoryType later if needed) + return traits.cls == CLASS_DEATH_KNIGHT || traits.cls == CLASS_HUNTER || traits.cls == CLASS_PALADIN || + traits.cls == CLASS_SHAMAN || traits.cls == CLASS_WARRIOR || + traits.cls == CLASS_ROGUE; // Rogue: 1H axes + + // Swords + case ITEM_SUBCLASS_WEAPON_SWORD: // 1H swords + return traits.cls == CLASS_DEATH_KNIGHT || traits.cls == CLASS_HUNTER || traits.cls == CLASS_MAGE || + traits.cls == CLASS_PALADIN || traits.cls == CLASS_ROGUE || traits.cls == CLASS_WARRIOR || + traits.cls == CLASS_WARLOCK; // Warlocks can use 1H swords + case ITEM_SUBCLASS_WEAPON_SWORD2: // 2H swords + return traits.cls == CLASS_DEATH_KNIGHT || traits.cls == CLASS_HUNTER || traits.cls == CLASS_PALADIN || + traits.cls == CLASS_WARRIOR; + + // Maces + case ITEM_SUBCLASS_WEAPON_MACE: // 1H maces + return traits.cls == CLASS_DEATH_KNIGHT || traits.cls == CLASS_DRUID || traits.cls == CLASS_PALADIN || + traits.cls == CLASS_PRIEST || traits.cls == CLASS_SHAMAN || traits.cls == CLASS_WARRIOR || + traits.cls == CLASS_ROGUE; // Rogue: 1H maces in WotLK + case ITEM_SUBCLASS_WEAPON_MACE2: // 2H maces + return traits.cls == CLASS_DEATH_KNIGHT || traits.cls == CLASS_DRUID || traits.cls == CLASS_PALADIN || + traits.cls == CLASS_WARRIOR; // Shaman: no 2H maces + + // Polearms + case ITEM_SUBCLASS_WEAPON_POLEARM: + return traits.cls == CLASS_DEATH_KNIGHT || traits.cls == CLASS_DRUID || traits.cls == CLASS_HUNTER || + traits.cls == CLASS_PALADIN || traits.cls == CLASS_WARRIOR; // Shaman: cannot use polearms + + // Staves + case ITEM_SUBCLASS_WEAPON_STAFF: + return traits.cls == CLASS_DRUID || traits.cls == CLASS_HUNTER || traits.cls == CLASS_MAGE || + traits.cls == CLASS_PRIEST || traits.cls == CLASS_SHAMAN || traits.cls == CLASS_WARLOCK; + + // Daggers + case ITEM_SUBCLASS_WEAPON_DAGGER: + return traits.cls == CLASS_DRUID || traits.cls == CLASS_HUNTER || traits.cls == CLASS_MAGE || + traits.cls == CLASS_PRIEST || traits.cls == CLASS_ROGUE || traits.cls == CLASS_WARLOCK || + traits.cls == CLASS_WARRIOR; // Warriors can use daggers + + // Fist weapons + case ITEM_SUBCLASS_WEAPON_FIST: + return traits.cls == CLASS_DRUID || traits.cls == CLASS_HUNTER || traits.cls == CLASS_ROGUE || + traits.cls == CLASS_SHAMAN || traits.cls == CLASS_WARRIOR; + + // Ranged (bows / guns / crossbows) — Hunters primary; also usable by Warriors/Rogues + case ITEM_SUBCLASS_WEAPON_BOW: + case ITEM_SUBCLASS_WEAPON_GUN: + case ITEM_SUBCLASS_WEAPON_CROSSBOW: + return traits.cls == CLASS_HUNTER || traits.cls == CLASS_WARRIOR || traits.cls == CLASS_ROGUE; + + // Wands — only Mage/Priest/Warlock + case ITEM_SUBCLASS_WEAPON_WAND: + return traits.cls == CLASS_MAGE || traits.cls == CLASS_PRIEST || traits.cls == CLASS_WARLOCK; + + // Thrown — Warriors/Rogues (Hunters rarely need them; bows/guns/xbows preferred) + case ITEM_SUBCLASS_WEAPON_THROWN: + return traits.cls == CLASS_WARRIOR || traits.cls == CLASS_ROGUE; + + // Exotic / fishing / misc — disallow + case ITEM_SUBCLASS_WEAPON_EXOTIC: + case ITEM_SUBCLASS_WEAPON_EXOTIC2: + case ITEM_SUBCLASS_WEAPON_MISC: + case ITEM_SUBCLASS_WEAPON_FISHING_POLE: + default: + return false; + } +} + +static bool IsPrimaryForSpec(Player* bot, ItemTemplate const* proto) +{ + if (!bot || !proto) + return false; + + // Jewelry/cloaks: focus mainly on the stat profile (stat set) + const bool isJewelry = proto->InventoryType == INVTYPE_TRINKET || proto->InventoryType == INVTYPE_FINGER || + proto->InventoryType == INVTYPE_NECK || proto->InventoryType == INVTYPE_CLOAK; + + const SpecTraits traits = GetSpecTraits(bot); + + // HARD GUARD: never consider lower-tier armor as "primary" for the spec (body armor only) + if (!isJewelry && proto->Class == ITEM_CLASS_ARMOR && IsBodyArmorInvType(proto->InventoryType)) + { + if (IsLowerTierArmorForBot(bot, proto)) + { + return false; // forces NEED->GREED earlier when SmartNeedBySpec is enabled + } + } + + // Hard filter first: do not NEED weapons/shields/relics the class shouldn't use. + // If this returns false, the caller will downgrade to GREED (off-spec/unsupported). + if (!IsWeaponOrShieldOrRelicAllowedForClass(traits, proto)) + { + return false; + } + + // Flags class/spec + const bool isCasterSpec = traits.isCaster; + const bool isTankLikeSpec = traits.isTank; + const bool isPhysicalSpec = traits.isPhysical; + + // Loot Stats + const bool hasINT = HasAnyStat(proto, {ITEM_MOD_INTELLECT}); + const bool hasSPI = HasAnyStat(proto, {ITEM_MOD_SPIRIT}); + const bool hasMP5 = HasAnyStat(proto, {ITEM_MOD_MANA_REGENERATION}); + const bool hasSP = HasAnyStat(proto, {ITEM_MOD_SPELL_POWER}); + const bool hasSTR = HasAnyStat(proto, {ITEM_MOD_STRENGTH}); + const bool hasAGI = HasAnyStat(proto, {ITEM_MOD_AGILITY}); + const bool hasSTA = HasAnyStat(proto, {ITEM_MOD_STAMINA}); // Not used now, but i keep it we never know + const bool hasAP = HasAnyStat(proto, {ITEM_MOD_ATTACK_POWER, ITEM_MOD_RANGED_ATTACK_POWER}); + const bool hasARP = HasAnyStat(proto, {ITEM_MOD_ARMOR_PENETRATION_RATING}); + const bool hasEXP = HasAnyStat(proto, {ITEM_MOD_EXPERTISE_RATING}); + const bool hasHIT = HasAnyStat(proto, {ITEM_MOD_HIT_RATING}); + const bool hasHASTE = HasAnyStat(proto, {ITEM_MOD_HASTE_RATING}); + const bool hasCRIT = HasAnyStat(proto, {ITEM_MOD_CRIT_RATING}); + const bool hasDef = HasAnyStat(proto, {ITEM_MOD_DEFENSE_SKILL_RATING}); + const bool hasAvoid = HasAnyStat(proto, {ITEM_MOD_DODGE_RATING, ITEM_MOD_PARRY_RATING, ITEM_MOD_BLOCK_RATING}); + + // Quick profiles + const bool looksCaster = hasSP || hasSPI || hasMP5 || (hasINT && !hasSTR && !hasAGI && !hasAP); + const bool looksPhysical = hasSTR || hasAGI || hasAP || hasARP || hasEXP; + const bool hasDpsRatings = hasHIT || hasHASTE || hasCRIT; // Common to all DPS (physical & casters) + + // Tank-only profile: Defense / Avoidance (dodge/parry/block rating) / Block value + // Do NOT tag all shields as "tank": there are caster shields (INT/SP/MP5) + const bool hasBlockValue = HasAnyStat(proto, {ITEM_MOD_BLOCK_VALUE}); + const bool looksTank = hasDef || hasAvoid || hasBlockValue; + + // Do not let patterns override jewelry/cloak logic; these are handled separately below. + bool primaryByPattern = false; + if (!isJewelry && ApplyStatPatternsForPrimary(bot, proto, traits, primaryByPattern)) + { + return primaryByPattern; + } + + // Non-tanks (DPS, casters/heals) never NEED purely tank items + if (!isTankLikeSpec && looksTank) + { + return false; + } + + // Generic rules by role/family + if (isPhysicalSpec) + { + // (1) All physicals/tanks: never Spell Power/Spirit/MP5 (even if plate/mail) + if (looksCaster) + { + return false; + } + // (2) Weapon/shield with Spell Power: always off-spec for DK/War/Rogue/Hunter/Ret/Enh/Feral/Prot + if ((proto->Class == ITEM_CLASS_WEAPON || + (proto->Class == ITEM_CLASS_ARMOR && proto->SubClass == ITEM_SUBCLASS_ARMOR_SHIELD)) && + hasSP) + { + return false; + } + // (3) Jewelry/cloaks with caster stats (SP/SPI/MP5/pure INT) -> off-spec + if (isJewelry && looksCaster) + { + return false; + } + } + else // Caster/Healer + { + // (1) Casters/healers should not NEED pure melee items (STR/AP/ARP/EXP) without INT/SP + if (looksPhysical && !hasSP && !hasINT) + { + return false; + } + + // (2) Melee jewelry (AP/ARP/EXP/STR/AGI) without INT/SP -> off-spec + if (isJewelry && looksPhysical && !hasSP && !hasINT) + { + return false; + } + + // (3) Healers: treat pure "DPS caster Hit" pieces as off-spec. + // Profile: SP/INT + Hit and *no* regen stats (no SPI, no MP5). + // + // This only marks the item as *not primary* for healers. + // The actual priority is decided later by SmartNeedBySpec + GroupHasPrimarySpecUpgradeCandidate: + // - if a DPS caster bot has this item as a mainspec upgrade, healers are downgraded to GREED; + // - if nobody in the group has it as mainspec upgrade, healers are allowed to keep NEED. + if (traits.isHealer && hasHIT && !hasMP5 && !hasSPI) + { + return false; + } + + // Paladin Holy (plate INT+SP/MP5), Shaman Elemental/Restoration (mail INT+SP/MP5), + // Druid Balance/Restoration (leather/cloth caster) -> OK + } + + // Extra weapon sanity for Hunters/Ferals (avoid wrong stat-sticks): + // - Hunters: for melee weapons, require AGI (prevent Haste/AP-only daggers without AGI). + // - Feral (tank/DPS): for melee weapons, require AGI or STR. + if (proto->Class == ITEM_CLASS_WEAPON) + { + const bool meleeWeapon = + proto->InventoryType == INVTYPE_WEAPON || proto->InventoryType == INVTYPE_WEAPONMAINHAND || + proto->InventoryType == INVTYPE_WEAPONOFFHAND || proto->InventoryType == INVTYPE_2HWEAPON; + + if (meleeWeapon && traits.isHunter && !hasAGI) + { + return false; + } + + if (meleeWeapon && (traits.isFeralTk || traits.isFeralDps) && !hasAGI && !hasSTR) + { + return false; + } + + // Enhancement shamans prefer slow weapons for Windfury; avoid very fast melee weapons as main-spec. + if (meleeWeapon && traits.isEnhSham) + { + // Delay is in milliseconds; 2000 ms = 2.0s. Anything faster than this is treated as off-spec. + if (proto->Delay > 0 && proto->Delay < 2000) + { + return false; + } + } + } + + // Class/spec specific adjustments (readable) + // DK Unholy (DPS): allows STR/HIT/HASTE/CRIT/ARP; rejects all caster items + if (traits.cls == CLASS_DEATH_KNIGHT && (traits.spec == "unholy" || traits.spec == "uh") && looksCaster) + { + return false; + } + + // DK Blood/Frost tanks: DEF/AVOID/STA/STR are useful; reject caster items + if (traits.isDKTank && looksCaster) + { + return false; + } + // Pure caster DPS rings/trinkets already filtered above. + + // Hunter (BM/MM/SV): agi/hit/haste/AP/crit/arp → OK; avoid STR-only or caster items + if (traits.isHunter) + { + if (looksCaster) + { + return false; + } + // Avoid rings with "pure STR" without AGI/AP/DPS ratings + if (isJewelry && hasSTR && !hasAGI && !hasAP && !hasDpsRatings) + { + return false; + } + } + + // Rogue (all specs): same strict physical filter (no caster items) + if (traits.isRogue && looksCaster) + { + return false; + } + + // Rogue: do not treat INT leather body armor as primary (off-spec leveling pieces only). + if (traits.isRogue && proto->Class == ITEM_CLASS_ARMOR && IsBodyArmorInvType(proto->InventoryType) && + proto->SubClass == ITEM_SUBCLASS_ARMOR_LEATHER && hasINT) + { + return false; + } + + // Warrior Arms/Fury : no caster items + if (traits.isWarrior && !traits.isWarProt && looksCaster) + { + return false; + } + + // Warrior Protection: DEF/AVOID/STA/STR are useful; no caster items + if (traits.isWarProt && looksCaster) + { + return false; + } + + // Shaman Enhancement: no Spell Power weapons/shields, no pure INT/SP items + if (traits.isEnhSham) + { + if (looksCaster) + { + return false; + } + if ((proto->Class == ITEM_CLASS_WEAPON || + (proto->Class == ITEM_CLASS_ARMOR && proto->SubClass == ITEM_SUBCLASS_ARMOR_SHIELD)) && + hasSP) + { + return false; + } + } + + // Druid Feral (tank/DPS): AGI/STA/AVOID/ARP/EXP → OK; no caster items + if ((traits.isFeralTk || traits.isFeralDps) && looksCaster) + { + return false; + } + + // Paladin Retribution: physical DPS (no caster items; forbid SP weapons/shields; enforce 2H only) + if (traits.isRetPal) + { + if (looksCaster) + { + return false; + } + + // No Spell Power weapons or shields for Ret + if ((proto->Class == ITEM_CLASS_WEAPON || + (proto->Class == ITEM_CLASS_ARMOR && proto->SubClass == ITEM_SUBCLASS_ARMOR_SHIELD)) && + hasSP) + { + return false; + } + // Enforce 2H only (no 1H/off-hand/shields/holdables) + switch (proto->InventoryType) + { + case INVTYPE_WEAPON: // generic 1H + case INVTYPE_WEAPONMAINHAND: // explicit main-hand 1H + case INVTYPE_WEAPONOFFHAND: // off-hand weapon + case INVTYPE_SHIELD: // shields + case INVTYPE_HOLDABLE: // tomes/orbs + return false; // never NEED for Ret + default: + break; // INVTYPE_2HWEAPON is allowed; others handled elsewhere + } + } + + // Global VETO: a "physical" spec never considers a caster profile as primary + if (sPlayerbotAIConfig->smartNeedBySpec && traits.isPhysical && looksCaster) + { + return false; + } + + // Let the cross-armor rules (CrossArmorExtraMargin) decide for major off-armor upgrades. + return true; +} + +// Local mini-helper: maps an InventoryType (INVTYPE_*) to an EquipmentSlot (EQUIPMENT_SLOT_*) +// Only covers the slots relevant for T7-T10 tokens (head/shoulders/chest/hands/legs). +static uint8 EquipmentSlotByInvTypeSafe(uint8 invType) +{ + switch (invType) + { + case INVTYPE_HEAD: + return EQUIPMENT_SLOT_HEAD; + case INVTYPE_SHOULDERS: + return EQUIPMENT_SLOT_SHOULDERS; + case INVTYPE_CHEST: + case INVTYPE_ROBE: + return EQUIPMENT_SLOT_CHEST; + case INVTYPE_HANDS: + return EQUIPMENT_SLOT_HANDS; + case INVTYPE_LEGS: + return EQUIPMENT_SLOT_LEGS; + default: + return EQUIPMENT_SLOT_END; // unknown/not applicable + } +} + +// All equippable items -> corresponding slots +static void GetEquipSlotsForInvType(uint8 invType, std::vector& out) +{ + out.clear(); + switch (invType) + { + case INVTYPE_HEAD: + out = {EQUIPMENT_SLOT_HEAD}; + break; + case INVTYPE_NECK: + out = {EQUIPMENT_SLOT_NECK}; + break; + case INVTYPE_SHOULDERS: + out = {EQUIPMENT_SLOT_SHOULDERS}; + break; + case INVTYPE_BODY: /* shirt, ignore */ + break; + case INVTYPE_CHEST: + case INVTYPE_ROBE: + out = {EQUIPMENT_SLOT_CHEST}; + break; + case INVTYPE_WAIST: + out = {EQUIPMENT_SLOT_WAIST}; + break; + case INVTYPE_LEGS: + out = {EQUIPMENT_SLOT_LEGS}; + break; + case INVTYPE_FEET: + out = {EQUIPMENT_SLOT_FEET}; + break; + case INVTYPE_WRISTS: + out = {EQUIPMENT_SLOT_WRISTS}; + break; + case INVTYPE_HANDS: + out = {EQUIPMENT_SLOT_HANDS}; + break; + case INVTYPE_FINGER: + out = {EQUIPMENT_SLOT_FINGER1, EQUIPMENT_SLOT_FINGER2}; + break; + case INVTYPE_TRINKET: + out = {EQUIPMENT_SLOT_TRINKET1, EQUIPMENT_SLOT_TRINKET2}; + break; + case INVTYPE_CLOAK: + out = {EQUIPMENT_SLOT_BACK}; + break; + case INVTYPE_WEAPON: + out = {EQUIPMENT_SLOT_MAINHAND, EQUIPMENT_SLOT_OFFHAND}; + break; + case INVTYPE_2HWEAPON: + out = {EQUIPMENT_SLOT_MAINHAND}; + break; + case INVTYPE_SHIELD: + out = {EQUIPMENT_SLOT_OFFHAND}; + break; + case INVTYPE_WEAPONMAINHAND: + out = {EQUIPMENT_SLOT_MAINHAND}; + break; + case INVTYPE_WEAPONOFFHAND: + out = {EQUIPMENT_SLOT_OFFHAND}; + break; + case INVTYPE_HOLDABLE: + out = {EQUIPMENT_SLOT_OFFHAND}; + break; // tome/orb + case INVTYPE_RANGED: + case INVTYPE_THROWN: + case INVTYPE_RANGEDRIGHT: + out = {EQUIPMENT_SLOT_RANGED}; + break; + case INVTYPE_RELIC: + out = {EQUIPMENT_SLOT_RANGED}; + break; // totem/idol/sigil/libram + case INVTYPE_TABARD: + case INVTYPE_BAG: + case INVTYPE_AMMO: + case INVTYPE_QUIVER: + default: + break; // not relevant for gear + } +} + +// Internal prototypes +static bool CanBotUseToken(ItemTemplate const* proto, Player* bot); +static bool RollUniqueCheck(ItemTemplate const* proto, Player* bot); + +// WotLK Heuristic: We can only DE [UNCOMMON..EPIC] quality ARMOR/WEAPON +static inline bool IsLikelyDisenchantable(ItemTemplate const* proto) +{ + if (!proto) + { + return false; + } + if (proto->Class != ITEM_CLASS_ARMOR && proto->Class != ITEM_CLASS_WEAPON) + { + return false; + } + return proto->Quality >= ITEM_QUALITY_UNCOMMON && proto->Quality <= ITEM_QUALITY_EPIC; +} + +// Internal helpers +// Deduces the target slot from the token's name. +// Returns an expected InventoryType (HEAD/SHOULDERS/CHEST/HANDS/LEGS) or -1 if unknown. +static int8 TokenSlotFromName(ItemTemplate const* proto) +{ + if (!proto) + return -1; + std::string n = std::string(proto->Name1); + std::transform(n.begin(), n.end(), n.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (n.find("helm") != std::string::npos || n.find("head") != std::string::npos) + { + return INVTYPE_HEAD; + } + if (n.find("shoulder") != std::string::npos || n.find("mantle") != std::string::npos || + n.find("spauld") != std::string::npos) + { + return INVTYPE_SHOULDERS; + } + if (n.find("chest") != std::string::npos || n.find("tunic") != std::string::npos || + n.find("robe") != std::string::npos || n.find("breastplate") != std::string::npos || + n.find("chestguard") != std::string::npos) + { + return INVTYPE_CHEST; + } + if (n.find("glove") != std::string::npos || n.find("handguard") != std::string::npos || + n.find("gauntlet") != std::string::npos) + { + return INVTYPE_HANDS; + } + if (n.find("leg") != std::string::npos || n.find("pant") != std::string::npos || + n.find("trouser") != std::string::npos) + { + return INVTYPE_LEGS; + } + return -1; +} + +// Upgrade heuristic for a token: if the slot is known, +// consider it a "likely upgrade" if ilvl(token) >= ilvl(best equipped item in that slot) + margin. +static bool IsTokenLikelyUpgrade(ItemTemplate const* token, uint8 invTypeSlot, Player* bot) +{ + if (!token || !bot) + return false; + uint8 eq = EquipmentSlotByInvTypeSafe(invTypeSlot); + if (eq >= EQUIPMENT_SLOT_END) + { + return true; // unknown slot -> do not block Need + } + Item* oldItem = bot->GetItemByPos(INVENTORY_SLOT_BAG_0, eq); + if (!oldItem) + return true; // empty slot -> guaranteed upgrade + ItemTemplate const* oldProto = oldItem->GetTemplate(); + if (!oldProto) + return true; + float margin = sPlayerbotAIConfig->tokenILevelMargin; // configurable + return (float)token->ItemLevel >= (float)oldProto->ItemLevel + margin; +} bool LootRollAction::Execute(Event event) { @@ -22,25 +1317,29 @@ bool LootRollAction::Execute(Event event) std::vector rolls = group->GetRolls(); for (Roll*& roll : rolls) { - if (roll->playerVote.find(bot->GetGUID())->second != NOT_EMITED_YET) + // Avoid server crash, key may not exit for the bot on login + auto it = roll->playerVote.find(bot->GetGUID()); + if (it != roll->playerVote.end() && it->second != NOT_EMITED_YET) { continue; } + ObjectGuid guid = roll->itemGUID; - uint32 slot = roll->itemSlot; uint32 itemId = roll->itemid; - int32 randomProperty = 0; - if (roll->itemRandomPropId) - randomProperty = roll->itemRandomPropId; - else if (roll->itemRandomSuffix) - randomProperty = -((int)roll->itemRandomSuffix); + int32 randomProperty = EncodeRandomEnchantParam(roll->itemRandomPropId, roll->itemRandomSuffix); - RollVote vote = PASS; ItemTemplate const* proto = sObjectMgr->GetItemTemplate(itemId); if (!proto) continue; + LOG_DEBUG("playerbots", + "[LootRollDBG] start bot={} item={} \"{}\" class={} q={} lootMethod={} enchSkill={} rp={}", + bot->GetName(), itemId, proto->Name1, proto->Class, proto->Quality, (int)group->GetLootMethod(), + bot->HasSkill(SKILL_ENCHANTING), randomProperty); + + RollVote vote = PASS; std::string itemUsageParam; + if (randomProperty != 0) { itemUsageParam = std::to_string(itemId) + "," + std::to_string(randomProperty); @@ -51,39 +1350,65 @@ bool LootRollAction::Execute(Event event) } ItemUsage usage = AI_VALUE2(ItemUsage, "item usage", itemUsageParam); - // Armor Tokens are classed as MISC JUNK (Class 15, Subclass 0), luckily no other items I found have class bits and epic quality. - if (proto->Class == ITEM_CLASS_MISC && proto->SubClass == ITEM_SUBCLASS_JUNK && proto->Quality == ITEM_QUALITY_EPIC) + LOG_DEBUG("playerbots", "[LootRollDBG] usage={} (EQUIP=1 REPLACE=2 BAD_EQUIP=8 DISENCHANT=13)", (int)usage); + + // Armor Tokens are classed as MISC JUNK (Class 15, Subclass 0), but no other items have class bits and epic + // quality. + // - CanBotUseToken(proto, bot) => NEED + // - else => GREED + if (proto->Class == ITEM_CLASS_MISC && proto->SubClass == ITEM_SUBCLASS_JUNK && + proto->Quality == ITEM_QUALITY_EPIC) { if (CanBotUseToken(proto, bot)) { - vote = NEED; // Eligible for "Need" + // vote = NEED; // Eligible for Need + // Token mainspec: NEED only if the corresponding slot piece would be a real upgrade + int8 tokenSlot = TokenSlotFromName(proto); + if (tokenSlot >= 0) + { + if (IsTokenLikelyUpgrade(proto, (uint8)tokenSlot, bot)) + vote = NEED; + else + vote = GREED; + } + else + { + // Unknown slot (e.g. T10 sanctification tokens) + vote = GREED; + } } else { - vote = GREED; // Not eligible, so "Greed" + vote = GREED; // Not eligible, so Greed } } else { - switch (proto->Class) - { - case ITEM_CLASS_WEAPON: - case ITEM_CLASS_ARMOR: - if (usage == ITEM_USAGE_EQUIP || usage == ITEM_USAGE_REPLACE || usage == ITEM_USAGE_BAD_EQUIP) - { - vote = NEED; - } - else if (usage != ITEM_USAGE_NONE) - { - vote = GREED; - } - break; - default: - if (StoreLootAction::IsLootAllowed(itemId, botAI)) - vote = CalculateRollVote(proto); // Ensure correct Need/Greed behavior - break; - } + // Lets CalculateRollVote decide (includes SmartNeedBySpec, BoE/BoU, unique, cross-armor) + vote = CalculateRollVote(proto, randomProperty); + LOG_DEBUG("playerbots", "[LootRollDBG] after CalculateRollVote: vote={}", VoteTxt(vote)); + } + + // Disenchant (Need-Before-Greed): + // If the bot is ENCHANTING and the item is explicitly marked as "DISENCHANT" usage, prefer DE to GREED. + if (vote != NEED && sPlayerbotAIConfig->useDEButton && group && + (group->GetLootMethod() == NEED_BEFORE_GREED || group->GetLootMethod() == GROUP_LOOT) && + bot->HasSkill(SKILL_ENCHANTING) && IsLikelyDisenchantable(proto) && + usage == ITEM_USAGE_DISENCHANT) + { + LOG_DEBUG("playerbots", + "[LootRollDBG] DE switch: {} -> DISENCHANT (lootMethod={}, enchSkill={}, deOK=1, usage=DISENCHANT)", + VoteTxt(vote), static_cast(group->GetLootMethod()), bot->HasSkill(SKILL_ENCHANTING)); + vote = DISENCHANT; } + else + { + LOG_DEBUG("playerbots", + "[LootRollDBG] no DE: vote={} lootMethod={} enchSkill={} deOK={} usage={}", + VoteTxt(vote), static_cast(group->GetLootMethod()), bot->HasSkill(SKILL_ENCHANTING), + IsLikelyDisenchantable(proto), static_cast(usage)); + } + if (sPlayerbotAIConfig->lootRollLevel == 0) { vote = PASS; @@ -93,29 +1418,29 @@ bool LootRollAction::Execute(Event event) if (vote == NEED) { if (RollUniqueCheck(proto, bot)) - { - vote = PASS; - } + { + vote = PASS; + } else - { - vote = GREED; - } + { + vote = GREED; + } } else if (vote == GREED) { vote = PASS; } } - switch (group->GetLootMethod()) - { - case MASTER_LOOT: - case FREE_FOR_ALL: - group->CountRollVote(bot->GetGUID(), guid, PASS); - break; - default: - group->CountRollVote(bot->GetGUID(), guid, vote); - break; - } + // Announce + send the roll vote (if ML/FFA => PASS) + RollVote sent = vote; + if (group->GetLootMethod() == MASTER_LOOT || group->GetLootMethod() == FREE_FOR_ALL) + sent = PASS; + + LOG_DEBUG("playerbots", "[LootPaternDBG] send vote={} (lootMethod={} Lvl={}) -> guid={} itemId={}", + VoteTxt(sent), (int)group->GetLootMethod(), sPlayerbotAIConfig->lootRollLevel, guid.ToString(), + itemId); + + group->CountRollVote(bot->GetGUID(), guid, sent); // One item at a time return true; } @@ -123,33 +1448,239 @@ bool LootRollAction::Execute(Event event) return false; } -RollVote LootRollAction::CalculateRollVote(ItemTemplate const* proto) +RollVote LootRollAction::CalculateRollVote(ItemTemplate const* proto, int32 randomProperty) { + // Player mimic: upgrade => NEED; useful => GREED; otherwise => PASS std::ostringstream out; - out << proto->ItemId; + if (randomProperty != 0) + out << proto->ItemId << "," << randomProperty; + else + out << proto->ItemId; ItemUsage usage = AI_VALUE2(ItemUsage, "item usage", out.str()); - RollVote needVote = PASS; - switch (usage) + RollVote vote = PASS; + + bool recipeChecked = false; + bool recipeNeed = false; + bool recipeUseful = false; + bool recipeKnown = false; + uint32 reqSkillDbg = 0, reqRankDbg = 0, botRankDbg = 0; + + // Professions early override + // If enabled, bots NEED on recipes/patterns/books useful to their professions, + // provided they can learn them and don't already know the taught spell. + if (sPlayerbotAIConfig->needOnProfessionRecipes && IsRecipeItem(proto)) { - case ITEM_USAGE_EQUIP: - case ITEM_USAGE_REPLACE: - case ITEM_USAGE_GUILD_TASK: - case ITEM_USAGE_BAD_EQUIP: - needVote = NEED; - break; - case ITEM_USAGE_SKILL: - case ITEM_USAGE_USE: - case ITEM_USAGE_DISENCHANT: - case ITEM_USAGE_AH: - case ITEM_USAGE_VENDOR: - needVote = GREED; - break; - default: - break; + recipeChecked = true; + // Collect debug data (what the helper va décider) + reqSkillDbg = proto->RequiredSkill ? proto->RequiredSkill : GuessRecipeSkill(proto); + reqRankDbg = proto->RequiredSkillRank; + botRankDbg = reqSkillDbg ? bot->GetSkillValue(reqSkillDbg) : 0; + recipeKnown = BotAlreadyKnowsRecipeSpell(bot, proto); + + recipeUseful = IsProfessionRecipeUsefulForBot(bot, proto); + if (recipeUseful) + { + vote = NEED; + recipeNeed = true; + } + else + { + vote = GREED; // recipe not for the bot -> GREED + } + } + + // Do not overwrite the choice if we have already decided via the "recipe" logic + if (!recipeChecked) + { + switch (usage) + { + case ITEM_USAGE_EQUIP: + case ITEM_USAGE_REPLACE: + { + vote = NEED; + // SmartNeedBySpec: only downgrade to GREED if there is at least one + // "true mainspec upgrade" candidate in the group, or if this spec/item + // combination is too far off to justify NEED as off-spec. + if (sPlayerbotAIConfig->smartNeedBySpec && !IsPrimaryForSpec(bot, proto)) + { + if (GroupHasPrimarySpecUpgradeCandidate(bot, proto, randomProperty)) + { + vote = GREED; + } + else if (!IsFallbackNeedReasonableForSpec(bot, proto)) + { + // No mainspec candidate, but the item is too far off for this spec -> GREED. + vote = GREED; + } + else + { + LOG_DEBUG("playerbots", + "[LootRollDBG] secondary-fallback: no primary spec upgrade in group, {} may NEED item={} \"{}\"", + bot->GetName(), proto->ItemId, proto->Name1); + } + } + break; + } + case ITEM_USAGE_BAD_EQUIP: + case ITEM_USAGE_GUILD_TASK: + case ITEM_USAGE_SKILL: + case ITEM_USAGE_USE: + case ITEM_USAGE_DISENCHANT: + case ITEM_USAGE_AH: + case ITEM_USAGE_VENDOR: + case ITEM_USAGE_KEEP: + case ITEM_USAGE_AMMO: + vote = GREED; + break; + default: + vote = PASS; + break; + } + } + + // Policy: turn GREED into PASS on off-armor (lower tier) if configured + if (vote == GREED && proto->Class == ITEM_CLASS_ARMOR && sPlayerbotAIConfig->crossArmorGreedIsPass) + { + if (IsLowerTierArmorForBot(bot, proto)) + vote = PASS; + } + + // Lockboxes: if the item is a lockbox and the bot is a Rogue with Lockpicking, prefer NEED. + // (Handled before BoE/BoU etiquette; BoE/BoU checks below ignore lockboxes.) + const SpecTraits traits = GetSpecTraits(bot); + const bool isLockbox = IsLockbox(proto); + if (isLockbox && traits.isRogue && bot->HasSkill(SKILL_LOCKPICKING)) + vote = NEED; + + // Generic BoP rule: if the item is BoP, equippable, matches the spec + // AND at least one relevant slot is empty -> allow NEED + constexpr uint32 BIND_WHEN_PICKED_UP = 1; + if (vote != NEED && proto->Bonding == BIND_WHEN_PICKED_UP) + { + std::vector slots; + GetEquipSlotsForInvType(proto->InventoryType, slots); + if (!slots.empty()) + { + const bool specOk = !sPlayerbotAIConfig->smartNeedBySpec || IsPrimaryForSpec(bot, proto); + if (specOk) + { + for (uint8 s : slots) + { + if (!bot->GetItemByPos(INVENTORY_SLOT_BAG_0, s)) + { + vote = NEED; // fills an empty slot -> NEED + break; + } + } + } + } + } + + // BoE/BoU rule: by default, avoid NEED on Bind-on-Equip / Bind-on-Use (raid etiquette) + // Exception: Profession recipes/patterns/books (ITEM_CLASS_RECIPE) keep NEED if they are useful. + constexpr uint32 BIND_WHEN_EQUIPPED = 2; // BoE + constexpr uint32 BIND_WHEN_USE = 3; // BoU + + if (vote == NEED && !recipeNeed && !isLockbox && proto->Bonding == BIND_WHEN_EQUIPPED && + !sPlayerbotAIConfig->allowBoENeedIfUpgrade) + { + vote = GREED; + } + if (vote == NEED && !recipeNeed && !isLockbox && proto->Bonding == BIND_WHEN_USE && + !sPlayerbotAIConfig->allowBoUNeedIfUpgrade) + { + vote = GREED; + } + + // Duplicate soft rule (non-unique): + // - Default: if the bot already owns at least one copy => NEED -> GREED. + // - Exception: Book of Glyph Mastery (you can own several) => keep NEED. + if (vote == NEED) + { + if (!IsGlyphMasteryBook(proto)) + { + // includeBank=true to catch banked duplicates as well. + if (bot->GetItemCount(proto->ItemId, true) > 0) + vote = GREED; + } + } + + // Unique-equip: never NEED a duplicate (already equipped/owned) + if (vote == NEED && RollUniqueCheck(proto, bot)) + { + vote = PASS; + } + + // Cross-armor: if BAD_EQUIP (e.g. cloth for paladin), allow NEED only if + // - it is really lower-tier armor for this bot, and + // - no primary armor user in the group is likely to NEED it, and + // - it is a massive upgrade according to StatsWeightCalculator. + if (vote == GREED && usage == ITEM_USAGE_BAD_EQUIP && proto->Class == ITEM_CLASS_ARMOR && + IsLowerTierArmorForBot(bot, proto)) + { + if (!GroupHasPrimaryArmorUserLikelyToNeed(bot, proto, randomProperty)) + { + // Reuse the same sanity as the generic fallback: + // even in cross-armor mode, do not allow NEED on completely off-spec items + // (e.g. rogues on cloth SP/INT/SPI, casters on pure STR/AP plate, etc.). + if (!IsFallbackNeedReasonableForSpec(bot, proto)) + { + LOG_DEBUG("playerbots", + "[LootRollDBG] cross-armor: {} too far off-spec for item={} \"{}\", keeping GREED", + bot->GetName(), proto->ItemId, proto->Name1); + } + else + { + StatsWeightCalculator calc(bot); + float newScore = calc.CalculateItem(proto->ItemId); + float bestOld = 0.0f; + + // Find the best currently equipped item of the same InventoryType + for (uint8 slot = EQUIPMENT_SLOT_START; slot < EQUIPMENT_SLOT_END; ++slot) + { + Item* oldItem = bot->GetItemByPos(INVENTORY_SLOT_BAG_0, slot); + if (!oldItem) + continue; + + ItemTemplate const* oldProto = oldItem->GetTemplate(); + if (!oldProto) + continue; + if (oldProto->Class != ITEM_CLASS_ARMOR) + continue; + if (oldProto->InventoryType != proto->InventoryType) + continue; + + float oldScore = calc.CalculateItem( + oldProto->ItemId, oldItem->GetInt32Value(ITEM_FIELD_RANDOM_PROPERTIES_ID)); + if (oldScore > bestOld) + bestOld = oldScore; + } + + if (bestOld > 0.0f && newScore >= bestOld * sPlayerbotAIConfig->crossArmorExtraMargin) + vote = NEED; + } + } + else + { + LOG_DEBUG("playerbots", + "[LootRollDBG] cross-armor: keeping GREED, primary armor user present for item={} \"{}\"", + proto->ItemId, proto->Name1); + } + } + // Final decision (with allow/deny from loot strategy) + RollVote finalVote = StoreLootAction::IsLootAllowed(proto->ItemId, GET_PLAYERBOT_AI(bot)) ? vote : PASS; + + // DEBUG: dump for recipes + if (IsRecipeItem(proto)) + { + DebugRecipeRoll(bot, proto, usage, recipeChecked, recipeUseful, recipeKnown, reqSkillDbg, reqRankDbg, + botRankDbg, + /*before*/ (recipeNeed ? NEED : PASS), + /*after*/ finalVote); } - return StoreLootAction::IsLootAllowed(proto->ItemId, GET_PLAYERBOT_AI(bot)) ? needVote : PASS; + return finalVote; } bool MasterLootRollAction::isUseful() { return !botAI->HasActivePlayerMaster(); } @@ -170,7 +1701,7 @@ bool MasterLootRollAction::Execute(Event event) p.rpos(0); // reset packet pointer p >> creatureGuid; // creature guid what we're looting - p >> mapId; /// 3.3.3 mapid + p >> mapId; // 3.3.3 mapid p >> itemSlot; // the itemEntryId for the item that shall be rolled for p >> itemId; // the itemEntryId for the item that shall be rolled for p >> randomSuffix; // randomSuffix @@ -186,13 +1717,82 @@ bool MasterLootRollAction::Execute(Event event) if (!group) return false; - RollVote vote = CalculateRollVote(proto); - group->CountRollVote(bot->GetGUID(), creatureGuid, CalculateRollVote(proto)); + LOG_DEBUG("playerbots", + "[LootEnchantDBG][ML] start bot={} item={} \"{}\" class={} q={} lootMethod={} enchSkill={} rp={}", + bot->GetName(), itemId, proto->Name1, proto->Class, proto->Quality, (int)group->GetLootMethod(), + bot->HasSkill(SKILL_ENCHANTING), randomPropertyId); + + // Compute random property and usage, same pattern as LootRollAction::Execute + int32 randomProperty = EncodeRandomEnchantParam(randomPropertyId, randomSuffix); + + std::string itemUsageParam; + if (randomProperty != 0) + { + itemUsageParam = std::to_string(itemId) + "," + std::to_string(randomProperty); + } + else + { + itemUsageParam = std::to_string(itemId); + } + + ItemUsage usage = AI_VALUE2(ItemUsage, "item usage", itemUsageParam); + + // 1) Token heuristic: ONLY NEED if the target slot is a likely upgrade + RollVote vote = PASS; + if (proto->Class == ITEM_CLASS_MISC && proto->SubClass == ITEM_SUBCLASS_JUNK && + proto->Quality == ITEM_QUALITY_EPIC) + { + if (CanBotUseToken(proto, bot)) + { + int8 tokenSlot = TokenSlotFromName(proto); // Internal helper + if (tokenSlot >= 0) + vote = IsTokenLikelyUpgrade(proto, (uint8)tokenSlot, bot) ? NEED : GREED; + else + vote = GREED; // Unknow slot + } + else + { + vote = GREED; + } + } + else + { + vote = CalculateRollVote(proto, randomProperty); + } + + // 2) Disenchant button in Need-Before-Greed if the usage is explicitly "DISENCHANT" + if (vote != NEED && sPlayerbotAIConfig->useDEButton && + (group->GetLootMethod() == NEED_BEFORE_GREED || group->GetLootMethod() == GROUP_LOOT) && + bot->HasSkill(SKILL_ENCHANTING) && IsLikelyDisenchantable(proto) && + usage == ITEM_USAGE_DISENCHANT) + { + LOG_DEBUG("playerbots", + "[LootEnchantDBG][ML] DE switch: {} -> DISENCHANT (lootMethod={}, enchSkill={}, deOK=1, usage=DISENCHANT)", + VoteTxt(vote), static_cast(group->GetLootMethod()), bot->HasSkill(SKILL_ENCHANTING)); + vote = DISENCHANT; + } + else + { + LOG_DEBUG("playerbots", + "[LootEnchantDBG][ML] no DE: vote={} lootMethod={} enchSkill={} deOK={} usage={}", + VoteTxt(vote), static_cast(group->GetLootMethod()), bot->HasSkill(SKILL_ENCHANTING), + IsLikelyDisenchantable(proto), static_cast(usage)); + } + + RollVote sent = vote; + if (group->GetLootMethod() == MASTER_LOOT || group->GetLootMethod() == FREE_FOR_ALL) + sent = PASS; + + LOG_DEBUG("playerbots", "[LootEnchantDBG][ML] vote={} -> sent={} lootMethod={} enchSkill={} deOK={}", VoteTxt(vote), + VoteTxt(sent), (int)group->GetLootMethod(), bot->HasSkill(SKILL_ENCHANTING), + IsLikelyDisenchantable(proto)); + + group->CountRollVote(bot->GetGUID(), creatureGuid, sent); return true; } -bool CanBotUseToken(ItemTemplate const* proto, Player* bot) +static bool CanBotUseToken(ItemTemplate const* proto, Player* bot) { // Get the bitmask for the bot's class uint32 botClassMask = (1 << (bot->getClass() - 1)); @@ -200,13 +1800,13 @@ bool CanBotUseToken(ItemTemplate const* proto, Player* bot) // Check if the bot's class is allowed to use the token if (proto->AllowableClass & botClassMask) { - return true; // Bot's class is eligible to use this token + return true; // Bot's class is eligible to use this token } - return false; // Bot's class cannot use this token + return false; // Bot's class cannot use this token } -bool RollUniqueCheck(ItemTemplate const* proto, Player* bot) +static bool RollUniqueCheck(ItemTemplate const* proto, Player* bot) { // Count the total number of the item (equipped + in bags) uint32 totalItemCount = bot->GetItemCount(proto->ItemId, true); @@ -222,9 +1822,9 @@ bool RollUniqueCheck(ItemTemplate const* proto, Player* bot) } else if (proto->HasFlag(ITEM_FLAG_UNIQUE_EQUIPPABLE) && (bagItemCount > 1)) { - return true; // Unique item already in bag, don't roll for it + return true; // Unique item already in bag, don't roll for it } - return false; // Item is not equipped or in bags, roll for it + return false; // Item is not equipped or in bags, roll for it } bool RollAction::Execute(Event event) @@ -233,7 +1833,7 @@ bool RollAction::Execute(Event event) if (link.empty()) { - bot->DoRandomRoll(0,100); + bot->DoRandomRoll(0, 100); return false; } ItemIds itemIds = chat->parseItems(link); @@ -253,10 +1853,10 @@ bool RollAction::Execute(Event event) { case ITEM_CLASS_WEAPON: case ITEM_CLASS_ARMOR: - if (usage == ITEM_USAGE_EQUIP || usage == ITEM_USAGE_REPLACE || usage == ITEM_USAGE_BAD_EQUIP) - { - bot->DoRandomRoll(0,100); - } + if (usage == ITEM_USAGE_EQUIP || usage == ITEM_USAGE_REPLACE) + { + bot->DoRandomRoll(0, 100); + } } return true; } diff --git a/src/strategy/actions/LootRollAction.h b/src/strategy/actions/LootRollAction.h index 13d8958605..f078354a11 100644 --- a/src/strategy/actions/LootRollAction.h +++ b/src/strategy/actions/LootRollAction.h @@ -7,6 +7,7 @@ #define _PLAYERBOT_LOOTROLLACTION_H #include "QueryItemUsageAction.h" +#include "ItemTemplate.h" class PlayerbotAI; @@ -22,11 +23,28 @@ class LootRollAction : public QueryItemUsageAction bool Execute(Event event) override; protected: - RollVote CalculateRollVote(ItemTemplate const* proto); -}; + /** + * Default roll rule (outside Master Loot & outside tokens): + * - NEED if direct upgrade (ItemUsage = EQUIP/REPLACE) + * - GREED if useful but not an upgrade (BAD_EQUIP, USE, SKILL, DISENCHANT, AH, VENDOR, KEEP, AMMO) + * - PASS otherwise + * + * Safeguards: + * - SmartNeedBySpec: downgrade NEED->GREED if the item does not match the bot's main spec + * - BoP: if at least one relevant slot is empty, allow NEED (if spec is valid) + * - BoE/BoU: NEED blocked unless explicitly allowed by config (AllowBoENeedIfUpgrade / AllowBoUNeedIfUpgrade) + * - Cross-armor: BAD_EQUIP can become NEED only if newScore >= bestScore * CrossArmorExtraMargin + * + * Specific cases: + * - Tokens: NEED only if the targeted slot is a likely upgrade (ilvl heuristic), + * otherwise GREED (tokens with unknown slot remain GREED by default) + * - Disenchant (NBG): if ItemUsage = DISENCHANT and config enabled, vote DISENCHANT + * (the core enforces if the DE button is actually available) + */ -bool CanBotUseToken(ItemTemplate const* proto, Player* bot); -bool RollUniqueCheck(ItemTemplate const* proto, Player* bot); + // randomProperty: 0 (none) ; >0 = itemRandomPropId ; <0 = -itemRandomSuffix + RollVote CalculateRollVote(ItemTemplate const* proto, int32 randomProperty = 0); +}; class MasterLootRollAction : public LootRollAction {