Files
AzerothAI/deploy/classic/native/AzerothAI.cpp
T
2026-09-25 18:19:50 -07:00

194 lines
16 KiB
C++

#include "AzerothAI.h"
#include "Entities/Player.h"
#include "World/World.h"
#include "Server/WorldSession.h"
#include "Server/DBCStores.h"
#include "Globals/ObjectMgr.h"
#include "Globals/SharedDefines.h"
#include "Groups/Group.h"
#include "Config/Config.h"
#include "Database/DatabaseEnv.h"
#include "playerbot/PlayerbotAI.h"
#include "playerbot/PlayerbotAIConfig.h"
#include "playerbot/RandomPlayerbotMgr.h"
#include <atomic>
#include <algorithm>
#include <map>
#include <sstream>
#include <iomanip>
#include <ctime>
namespace {
struct Resident { uint32_t guid, account; std::string name; bool armor; };
std::map<uint32_t, Resident> roster;
std::atomic<uint32_t> armorGuid{0}, armorAccount{0};
std::string sql(std::string text) { CharacterDatabase.escape_string(text); return text; }
std::string quoted(const std::string& text) {
std::ostringstream out; out << '"';
for (unsigned char c : text) {
if (c == '"' || c == '\\') out << '\\' << c;
else if (c < 32) out << "\\u" << std::hex << std::setw(4) << std::setfill('0') << unsigned(c);
else out << c;
}
out << '"'; return out.str();
}
bool identified(Player* p) {
if (!p || !p->IsInWorld()) return false;
auto r = roster.find(p->GetGUIDLow());
return r != roster.end() && p->GetName() == r->second.name && p->GetSession()->GetAccountId() == r->second.account
&& p->GetPlayerbotAI();
}
bool managed(Player* p) { return identified(p) && !p->GetPlayerbotAI()->IsRealPlayer() && !p->GetPlayerbotAI()->HasRealPlayerMaster(); }
bool safeGroup(Player* p) {
Group* g = p->GetGroup(); if (!g) return true;
unsigned count = 0;
for (GroupReference* r = g->GetFirstMember(); r; r = r->next()) {
if (!managed(r->getSource())) return false;
++count;
}
return count == g->GetMembersCount();
}
bool free(Player* p) { return managed(p) && p->IsAlive() && !p->IsInCombat() && !p->IsTaxiFlying() && !p->IsNonMeleeSpellCasted(false); }
bool nearby(Player* a, Player* b, float range) { return free(a) && free(b) && a->GetTeam() == b->GetTeam() && a->GetMapId() == b->GetMapId() && a->GetDistance(b) <= range; }
void receipt(const std::string& id, bool ok, const std::string& result) {
CharacterDatabase.PExecute("UPDATE azerothai_commands SET status='%s',result='%s' WHERE id='%s' AND status='processing'", ok ? "completed" : "failed", sql(result).c_str(), sql(id).c_str());
}
void commands() {
auto q = CharacterDatabase.Query("SELECT id,guid,account_id,name,kind,argument,peer_guid,created_at,peer_account_id,peer_name,expires_at FROM azerothai_commands WHERE status='pending' ORDER BY created_at LIMIT 8");
if (!q) return;
do {
Field* f=q->Fetch(); std::string id=f[0].GetString(), kind=f[4].GetString(), arg=f[5].GetString();
// One world-thread consumer claims durably before any game effect.
if (!CharacterDatabase.DirectPExecute("UPDATE azerothai_commands SET status='processing' WHERE id='%s' AND status='pending'",sql(id).c_str())) continue;
Player* p=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,f[1].GetUInt32()));
Player* peer=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,f[6].GetUInt32()));
if (!managed(p) || p->GetSession()->GetAccountId()!=f[2].GetUInt32() || std::string(p->GetName())!=f[3].GetString() || std::time(nullptr)-f[7].GetUInt32()>90 || std::time(nullptr)>f[10].GetUInt32()) { receipt(id,false,"Stale command or managed identity unavailable"); continue; }
bool humanReply=kind=="reply-say" || kind=="reply-whisper";
bool peerAllowed=humanReply ? peer && peer->IsInWorld() && (!peer->GetPlayerbotAI() || peer->GetPlayerbotAI()->IsRealPlayer()) : managed(peer);
if (f[6].GetUInt32() && (!peerAllowed || peer->GetSession()->GetAccountId()!=f[8].GetUInt32() || std::string(peer->GetName())!=f[9].GetString())) {receipt(id,false,"Peer identity changed or protected");continue;}
if (humanReply) {
if (arg.empty() || arg.size()>1000 || !peer || p->GetTeam()!=peer->GetTeam() || (kind=="reply-say" && (p->GetMapId()!=peer->GetMapId() || p->GetDistance(peer)>25))) {receipt(id,false,"Player conversation no longer available");continue;}
if(kind=="reply-whisper")p->Whisper(arg,LANG_UNIVERSAL,peer->GetObjectGuid());
else p->Say(arg,p->GetTeam()==ALLIANCE?LANG_COMMON:LANG_ORCISH);
receipt(id,true,"In-game player reply sent");
} else if (kind=="party") {
if (!nearby(p,peer,35) || !safeGroup(p) || !safeGroup(peer) || peer->GetGroup() || peer->GetGroupInvite() || p->GetGroupInvite() || (p->GetGroup() && (p->GetGroup()->GetMembersCount()>=5 || p->GetGroup()->GetLeaderGuid()!=p->GetObjectGuid()))) { receipt(id,false,"Party participants unavailable or protected"); continue; }
WorldPacket invite(CMSG_GROUP_INVITE,32); invite << std::string(peer->GetName()); p->GetSession()->HandleGroupInviteOpcode(invite);
if (peer->GetGroupInvite()) { WorldPacket accept(CMSG_GROUP_ACCEPT,0); peer->GetSession()->HandleGroupAcceptOpcode(accept); }
receipt(id,p->GetGroup() && p->GetGroup()==peer->GetGroup(),"Native party invitation processed");
} else if (kind=="recruit") {
if (!nearby(p,peer,35) || !p->GetGuildId() || p->GetRank()!=0 || peer->GetGuildId() || peer->GetGuildIdInvited()) {receipt(id,false,"Guild participants unavailable or protected");continue;}
WorldPacket invite(CMSG_GUILD_INVITE,32); invite << std::string(peer->GetName());p->GetSession()->HandleGuildInviteOpcode(invite);
if(peer->GetGuildIdInvited()==p->GetGuildId()){WorldPacket accept(CMSG_GUILD_ACCEPT,0);peer->GetSession()->HandleGuildAcceptOpcode(accept);}
receipt(id,peer->GetGuildId()==p->GetGuildId(),"Native guild invitation processed");
} else if (kind=="leave") {
if (!free(p) || !safeGroup(p) || !p->GetGroup() || std::to_string(p->GetGroup()->GetId())!=arg) { receipt(id,false,"Party changed or protected"); continue; }
WorldPacket packet(CMSG_GROUP_DISBAND,0); p->GetSession()->HandleGroupDisbandOpcode(packet); receipt(id,!p->GetGroup(),"Native departure processed");
} else if (kind=="say") {
if (!nearby(p,peer,25) || arg.empty() || arg.size()>1000) { receipt(id,false,"Conversation participants unavailable nearby"); continue; }
p->Say(arg,p->GetTeam()==ALLIANCE?LANG_COMMON:LANG_ORCISH); receipt(id,true,"In-game speech sent");
} else if (kind=="intent") {
if (!p->IsAlive() || p->IsTaxiFlying() || p->IsInCombat() || !safeGroup(p)) { receipt(id,false,"Native recovery, travel or combat takes priority"); continue; }
PlayerbotAI* ai=p->GetPlayerbotAI(); bool accepted=false;
if (arg=="quest" || arg=="explore") { ai->ChangeStrategy("-stay,+rpg,+grind",BotState::BOT_STATE_NON_COMBAT); ai->DoNextAction(); accepted=true; }
else if (arg=="rest") { accepted=ai->DoSpecificAction("eat") || ai->DoSpecificAction("drink"); }
else if (arg=="trade") { accepted=ai->DoSpecificAction("repair") || ai->DoSpecificAction("sell") || ai->DoSpecificAction("buy"); }
else if (arg=="socialize") { p->HandleEmoteCommand(EMOTE_ONESHOT_WAVE); accepted=true; }
receipt(id,accepted,accepted?"Intention submitted to native AI; outcome requires observation":"Native AI did not accept this intention");
} else receipt(id,false,"Unsupported Classic command");
} while(q->NextRow());
}
void snapshot(Player* p) {
std::ostringstream out; std::string members, nearbyNames; Group* group=p->GetGroup();
if(group) for(GroupReference* r=group->GetFirstMember();r;r=r->next()) if(Player* m=r->getSource()) { if(!members.empty())members+=","; members+=m->GetName(); }
for(auto const& r:roster) if(r.first!=p->GetGUIDLow()) { Player* m=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,r.first)); if(managed(m)&&m->GetTeam()==p->GetTeam()&&m->GetMapId()==p->GetMapId()&&p->GetDistance(m)<=35) { if(!nearbyNames.empty())nearbyNames+=","; nearbyNames+=m->GetName(); } }
out << "{\"isBot\":true,\"characterGuid\":" << p->GetGUIDLow() << ",\"accountId\":" << p->GetSession()->GetAccountId() << ",\"name\":" << quoted(p->GetName())
<< ",\"level\":" << p->GetLevel() << ",\"race\":" << unsigned(p->getRace()) << ",\"class\":" << unsigned(p->getClass())
<< ",\"team\":" << (p->GetTeam()==ALLIANCE?0:1) << ",\"mapId\":" << p->GetMapId() << ",\"zoneId\":" << p->GetZoneId()
<< ",\"x\":" << p->GetPositionX() << ",\"y\":" << p->GetPositionY() << ",\"healthPct\":" << p->GetHealthPercent()
<< ",\"xp\":" << p->GetUInt32Value(PLAYER_XP) << ",\"observedAt\":" << std::time(nullptr)
<< ",\"isDead\":" << (p->IsAlive()?"false":"true") << ",\"isGhost\":" << (p->HasFlag(PLAYER_FLAGS,PLAYER_FLAGS_GHOST)?"true":"false")
<< ",\"inCombat\":" << (p->IsInCombat()?"true":"false") << ",\"casting\":" << (p->IsNonMeleeSpellCasted(false)?"true":"false")
<< ",\"taxi\":" << (p->IsTaxiFlying()?"true":"false") << ",\"humanControlled\":" << (managed(p)?"false":"true")
<< ",\"groupId\":" << quoted(group?std::to_string(group->GetId()):"") << ",\"groupNames\":" << quoted(members)
<< ",\"nearbyBots\":" << quoted(nearbyNames) << ",\"guildId\":" << p->GetGuildId() << ",\"guildRank\":" << p->GetRank()
<< ",\"quests\":[";
bool first=true;for(uint8 i=0;i<MAX_QUEST_LOG_SIZE;++i) { uint32 id=p->GetQuestSlotQuestId(i);if(id){if(!first)out<<',';out<<id;first=false;} }
out << ']'; if(p->GetPowerType()==POWER_MANA && p->GetMaxPower(POWER_MANA)) out << ",\"manaPct\":" << 100.0*p->GetPower(POWER_MANA)/p->GetMaxPower(POWER_MANA);
if(auto area=GetAreaEntryByAreaID(p->GetZoneId())) out << ",\"zoneName\":" << quoted(area->area_name[0]);
out << '}';
CharacterDatabase.PExecute("REPLACE INTO azerothai_snapshots (guid,at,payload) VALUES (%u,UNIX_TIMESTAMP(),'%s')",p->GetGUIDLow(),sql(out.str()).c_str());
}
}
uint32_t AzerothAIBoundDamage(Unit* victim,uint32_t damage) {
if(victim && victim->GetTypeId()==TYPEID_PLAYER && victim->GetGUIDLow()==armorGuid.load()) {
Player* p=static_cast<Player*>(victim);
if(p->GetSession()->GetAccountId()==armorAccount.load() && p->GetPlayerbotAI() && !p->GetPlayerbotAI()->IsRealPlayer()) return std::min(damage,p->GetHealth()>1?p->GetHealth()-1:0u);
}
return damage;
}
bool AzerothAIAllowBotLogin(uint32_t guid) {
if(!sConfig.GetBoolDefault("AzerothAI.RestrictBotLogin",false))return true;
auto count=CharacterDatabase.Query("SELECT COUNT(*) FROM azerothai_roster WHERE enabled=1");
if(!count || count->Fetch()[0].GetUInt32()>50)return false;
auto identity=CharacterDatabase.PQuery("SELECT c.guid FROM characters c JOIN azerothai_roster r ON r.guid=c.guid AND r.account_id=c.account AND r.name=c.name WHERE r.enabled=1 AND c.guid=%u",guid);
return bool(identity);
}
void AzerothAIUpdate(uint32_t diff) {
static uint32_t elapsed=0; elapsed+=diff;if(elapsed<5000)return;elapsed=0;
if(!sConfig.GetBoolDefault("AzerothAI.Enable",false)){armorGuid=0;armorAccount=0;return;}
static bool started=false; if(!started){if(!CharacterDatabase.DirectExecute("UPDATE azerothai_commands SET status='failed',result='Server restarted during execution; outcome unknown, command not replayed' WHERE status='processing'"))return;started=true;}
auto q=CharacterDatabase.Query("SELECT guid,account_id,name,plot_armor FROM azerothai_roster WHERE enabled=1 ORDER BY guid LIMIT 51");
if(!q || q->GetRowCount()>50){roster.clear();armorGuid=0;armorAccount=0;return;}
roster.clear();armorGuid=0;armorAccount=0;
do { Field* f=q->Fetch();Resident r{f[0].GetUInt32(),f[1].GetUInt32(),f[2].GetString(),f[3].GetBool()};if(!r.guid||!r.account)continue;roster.emplace(r.guid,r);if(r.armor && r.name=="Zaku"){armorAccount=r.account;armorGuid=r.guid;} }while(q->NextRow());
// Population is drawn exclusively from the explicit identity allowlist.
static std::map<uint32_t,time_t> loginAttempt;
for(auto const& pair:roster){auto const&r=pair.second;Player* p=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,r.guid));
if(!p){if(std::time(nullptr)-loginAttempt[r.guid]>=60){auto identity=CharacterDatabase.PQuery("SELECT guid FROM characters WHERE guid=%u AND account=%u AND name='%s'",r.guid,r.account,sql(r.name).c_str());if(identity){sRandomPlayerbotMgr.AddPlayerBot(r.guid,0);loginAttempt[r.guid]=std::time(nullptr);}}continue;}
if(identified(p))snapshot(p);
// Honor the configured native auto-learn policy even for externally
// maintained bot sessions. Do not grant spells through a second path.
static std::map<uint32_t,uint32_t> trainedLevel;
if(sPlayerbotAIConfig.autoLearnTrainerSpells && free(p) && trainedLevel[r.guid]!=p->GetLevel()){
if(p->GetPlayerbotAI()->DoSpecificAction("auto learn spell"))trainedLevel[r.guid]=p->GetLevel();
}
}
commands();
}
void AzerothAIEvent(Player* p, const char* kind, uint32_t value, uint32_t amount) {
if (!sConfig.GetBoolDefault("AzerothAI.Enable",false) || !managed(p)) return;
std::string eventKind(kind), column;
if(eventKind=="player.quest.complete")column="quests";
else if(eventKind=="player.quest.reward")column="rewarded";
else if(eventKind=="player.loot.item")column="lootItems";
else if(eventKind=="player.loot.money")column="lootedCopper";
else if(eventKind=="player.death")column="nativeDeaths";
else if(eventKind=="player.spell.learn")column="learnedSpells";
else if(eventKind=="player.resurrect")column="resurrections";
if(!column.empty())CharacterDatabase.PExecute("INSERT INTO azerothai_counters(guid,%s) VALUES(%u,%u) ON DUPLICATE KEY UPDATE %s=%s+%u",column.c_str(),p->GetGUIDLow(),amount,column.c_str(),column.c_str(),amount);
if(eventKind=="player.loot.item")CharacterDatabase.PExecute("INSERT INTO azerothai_counters(guid,lootEvents) VALUES(%u,1) ON DUPLICATE KEY UPDATE lootEvents=lootEvents+1",p->GetGUIDLow());
std::ostringstream out;
out << "{\"isBot\":true,\"characterGuid\":" << p->GetGUIDLow() << ",\"accountId\":" << p->GetSession()->GetAccountId()
<< ",\"name\":" << quoted(p->GetName()) << ",\"level\":" << p->GetLevel() << ",\"observedAt\":" << std::time(nullptr)
<< ",\"value\":" << value << ",\"amount\":" << amount;
if(auto area=GetAreaEntryByAreaID(p->GetZoneId()))out << ",\"zoneName\":" << quoted(area->area_name[0]);
out << '}';
CharacterDatabase.PExecute("INSERT INTO azerothai_events(at,kind,payload) VALUES(UNIX_TIMESTAMP(),'%s','%s')",sql(eventKind).c_str(),sql(out.str()).c_str());
}
void AzerothAIChat(Player* p, const std::string& message, Player* receiver) {
if(!sConfig.GetBoolDefault("AzerothAI.Enable",false) || !p || !p->IsInWorld() || (p->GetPlayerbotAI() && !p->GetPlayerbotAI()->IsRealPlayer()) || message.empty() || message.size()>500 || message[0]=='.' || message[0]=='!')return;
if(receiver && !managed(receiver))return;
std::string names;
for(auto const& r:roster){Player* bot=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,r.first));if(managed(bot) && bot->GetTeam()==p->GetTeam() && bot->GetMapId()==p->GetMapId() && bot->GetDistance(p)<=25){if(!names.empty())names+=",";names+=bot->GetName();}}
if(!receiver && names.empty())return;
std::ostringstream out;
out << "{\"isBot\":false,\"characterGuid\":" << p->GetGUIDLow() << ",\"accountId\":" << p->GetSession()->GetAccountId() << ",\"name\":" << quoted(p->GetName())
<< ",\"message\":" << quoted(message) << ",\"nearbyBots\":" << quoted(names) << ",\"receiver\":" << quoted(receiver?receiver->GetName():"") << ",\"receiverIsBot\":" << (receiver?"true":"false");
if(auto area=GetAreaEntryByAreaID(p->GetZoneId()))out << ",\"zoneName\":" << quoted(area->area_name[0]);
out << '}';
CharacterDatabase.PExecute("INSERT INTO azerothai_events(at,kind,payload) VALUES(UNIX_TIMESTAMP(),'%s','%s')",receiver?"player.whisper":"player.chat",sql(out.str()).c_str());
}