-- AzerothAI <-> AzerothCore ALE bridge -- Install this file into AzerothCore/lua_scripts after building mod-ale. -- -- The live command/event transport is intentionally database-backed: -- AzerothAI API <-> host relay <-> acore_auth spool <-> ALE -- This avoids mod-ale's outbound HTTP worker entirely for the control plane. -- -- Optional worldserver.conf additions: -- AzerothAI.PollMs = "750" -- AzerothAI.ProtectedCharacter = "Zaku" local POLL_MS = tonumber(GetConfigValue("AzerothAI.PollMs")) or 750 local PROTECTED = GetConfigValue("AzerothAI.ProtectedCharacter") if not PROTECTED or PROTECTED == "" then PROTECTED = "Zaku" end local polling = false -- The deployment helper fills this with authoritative RNDBOT account IDs. -- This makes bot detection work even on an older mod-ale build that does not -- yet expose IsPlayerbot()/IsRandomBot() to Lua. local KNOWN_BOT_ACCOUNTS = {} local function is_playerbot(player) if not player then return false end local ok,value = pcall(function() return player:IsPlayerbot() end) if ok and value then return true end ok,value = pcall(function() return player:IsRandomBot() end) if ok and value then return true end ok,value = pcall(function() return player:IsBot() end) if ok and value then return true end local okAccount,accountId = pcall(function() return player:GetAccountId() end) if okAccount and KNOWN_BOT_ACCOUNTS[tonumber(accountId) or -1] then return true end return false end local function escape_json(value) value = tostring(value or "") value = string.gsub(value, "\\", "\\\\") value = string.gsub(value, '"', '\\"') value = string.gsub(value, "\r", "\\r") value = string.gsub(value, "\n", "\\n") value = string.gsub(value, "\t", "\\t") return value end local function json_value(value) local t = type(value) if value == nil then return "null" end if t == "boolean" then return value and "true" or "false" end if t == "number" then return tostring(value) end if t == "table" then local parts={} for _,item in ipairs(value) do parts[#parts+1]=json_value(item) end return "["..table.concat(parts,",").."]" end return '"' .. escape_json(value) .. '"' end local function encode_object(tbl) local parts = {} for k,v in pairs(tbl or {}) do table.insert(parts, '"' .. escape_json(k) .. '":' .. json_value(v)) end return "{" .. table.concat(parts, ",") .. "}" end local function to_hex(value) value=tostring(value or "") local out={} for i=1,#value do out[#out+1]=string.format("%02X",string.byte(value,i)) end return table.concat(out) end local function db_text(hex) return "CONVERT(UNHEX('" .. hex .. "') USING utf8mb4)" end function AzerothAIComplete(id, ok, result) local sql="INSERT INTO azerothai_result_outbox(command_id,ok,result) VALUES (" .. db_text(to_hex(id)) .. "," .. (ok and "1" or "0") .. "," .. db_text(to_hex(result)) .. ") ON DUPLICATE KEY UPDATE ok=VALUES(ok),result=VALUES(result),delivered_at=NULL" AuthDBExecute(sql) end local function emit(kind, payload) local body=encode_object(payload) local sql="INSERT INTO azerothai_event_outbox(kind,payload) VALUES (" .. db_text(to_hex(kind)) .. "," .. db_text(to_hex(body)) .. ")" AuthDBExecute(sql) end local function area_name(id) local ok,name=pcall(GetAreaName,id) return ok and name or tostring(id) end local function player_payload(player) if not player then return {} end local isBot = is_playerbot(player) return { name=player:GetName(), characterGuid=player:GetGUIDLow(), accountId=player:GetAccountId(), level=player:GetLevel(), mapId=player:GetMapId(), zoneId=player:GetZoneId(), areaId=player:GetAreaId(), zoneName=area_name(player:GetZoneId()), race=player:GetRace(), class=player:GetClass(), raceName=player:GetRaceAsString() or tostring(player:GetRace()), className=player:GetClassAsString() or tostring(player:GetClass()), healthPct=player:GetHealthPct(), observedAt=os.time(),team=player:GetTeam(),x=player:GetX(),y=player:GetY(), guildId=player:GetGuildId(),guildName=player:GetGuildName() or "",guildRank=player:GetGuildRank(), -- UNIT_STATE_IN_FLIGHT: IsTaxi() identifies flight-master NPCs, not flying players. inCombat=player:IsInCombat(),casting=player:IsCasting(),taxi=player:HasUnitState(0x00000100),movementType=player:GetMovementType(), isBot=isBot } end local function selected_bot_name(player) local ok,target=pcall(function() return player:GetSelection() end) if not ok or not target or not is_playerbot(target) then return "" end local okName,name=pcall(function() return target:GetName() end) if okName and name then return tostring(name) end return "" end local function nearby_bot_names(player) local ok,players=pcall(function() return player:GetPlayersInRange(35) end) if not ok or type(players)~="table" then return "" end local names={} for _,other in pairs(players) do if other and is_playerbot(other) then local okName,name=pcall(function() return other:GetName() end) if okName and name and name~="" then table.insert(names,tostring(name)) end end end table.sort(names) return table.concat(names,",") end -- Read only server knowledge; cache static quest text and bound prompt size. local questKnowledge={} local function decision_knowledge(player,p) p.xp=player:GetXP() local quests={} for _,id in ipairs(p.quests) do if #quests>=8 then break end local q=questKnowledge[id] if not q then local row=WorldDBQuery('SELECT LogTitle,QuestLevel FROM quest_template WHERE ID='..id) q=row and {title=string.sub(row:GetString(0) or '',1,100),level=row:GetInt32(1)} or {title='Quest '..id,level=0} questKnowledge[id]=q end local ok,status=pcall(function() return player:GetQuestStatus(id) end) local label=ok and (status==1 and 'ready to turn in' or status==3 and 'in progress' or status==5 and 'failed' or 'status '..tostring(status)) or 'status unavailable' quests[#quests+1]=id..': '..q.title..' (level '..q.level..', '..label..')' end p.questBrief=table.concat(quests,'; ') local threats={} for _,enemy in pairs(player:GetUnfriendlyUnitsInRange(35) or {}) do if not enemy:IsDead() then threats[#threats+1]=enemy end end table.sort(threats,function(a,b)return player:GetDistance(a)0 and ' (non-normal rank)' or '') end p.threatBrief=table.concat(descriptions,'; ') end local function world_payload(player) local p=player_payload(player) local ai=player:IsPlayerbot() and player:GetBotAI() or nil p.humanControlled=ai and ai:HasGameClientMaster() or false p.quests={};if ai then for slot=0,24 do local q=player:GetQuestSlotQuestId(slot);if q and q>0 then p.quests[#p.quests+1]=q end end end if ai then p.combatRole=player:IsHealer() and 'healer' or player:IsTank() and 'tank' or 'damage' end if AzerothAISurvivalState then p.dangerAt,p.survivalAction,p.survivalAt,p.survivalSince=AzerothAISurvivalState(player) end local g=player:GetGroup() p.groupId=g and tostring(g:GetGUID()) or "" local members={} if g then for _,m in pairs(g:GetMembers()) do members[#members+1]=m:GetName() end end p.groupNames=table.concat(members,",") p.isDead=player:IsDead();p.isGhost=player:HasAura(8326) p.manaPct=player:GetPowerType()==0 and player:GetPowerPct(0) or nil if p.manaPct and (p.manaPct~=p.manaPct or p.manaPct==math.huge) then p.manaPct=nil end local leader=ai and ai:GetGroupLeader() p.leaderName=leader and leader:GetName() or '' if AzerothAIExperienceSnapshot then for k,v in pairs(AzerothAIExperienceSnapshot(player)) do p[k]=v end end if AzerothAITrainingSnapshot then for k,v in pairs(AzerothAITrainingSnapshot(player)) do p[k]=v end end if AzerothAIReadinessSnapshot then for k,v in pairs(AzerothAIReadinessSnapshot(player)) do p[k]=v end end local ok,err=pcall(decision_knowledge,player,p) if not ok then p.questBrief=nil;p.threatBrief=nil end p.nearbyBots=nearby_bot_names(player) return p end function AzerothAIEmitPlayerSnapshot(name) if not name or name == "" then return 0 end local ok,player=pcall(function() return GetPlayerByName(name) end) if not ok or not player or not is_playerbot(player) then return 0 end emit("player.snapshot",world_payload(player)) return 1 end function AzerothAIEmitPermanentDeath(player) local payload=player_payload(player) payload.deathId="hardcore-"..tostring(player:GetGUIDLow()) emit("player.permanent-death",payload) end local function mark_consumed(id) AuthDBExecute("UPDATE azerothai_command_inbox SET consumed_at=NOW() WHERE id=" .. db_text(to_hex(id))) end local function poll() if polling then return end polling=true local ok,err=pcall(function() local q=AuthDBQuery("SELECT id,source FROM azerothai_command_inbox WHERE consumed_at IS NULL ORDER BY created_at,id LIMIT 1") if not q then return end local id=q:GetString(0) local source=q:GetString(1) local loader=load or loadstring local chunk,compileErr=loader(source) if not chunk then AzerothAIComplete(id,false,"command compile failed: "..tostring(compileErr)) mark_consumed(id) return end local ran,runErr=pcall(chunk) if not ran then AzerothAIComplete(id,false,"command crashed: "..tostring(runErr)) end mark_consumed(id) end) polling=false if not ok then PrintError("[AzerothAI] DB command poll crashed: "..tostring(err)) end end -- Durable command lane: the host relay places wrapped Lua commands into the -- auth database, so ALE never depends on its fragile outbound HTTP client. CreateLuaEvent(function() poll() end, POLL_MS, 0) -- Keep AzerothAI grounded in real online Playerbots even when no human is logged in. -- Expose a safe global refresh hook too, so the autonomy worker can explicitly -- request a fresh authoritative snapshot if the periodic timer ever stalls. local socialLast={} function AzerothAIEmitSnapshots() local ok,players=pcall(function() return GetPlayersInWorld() end) if not ok or type(players)~="table" then return 0 end local emitted=0 local guids={} for _,player in pairs(players) do if is_playerbot(player) then guids[#guids+1]=player:GetGUIDLow() emitted=emitted+1 end end -- Use snapshot transport so roster reconciliation shares fresh-event priority. local roster={roster=true,observedAt=os.time(),characterGuids=guids} local ordered={} for _,p in pairs(players) do if is_playerbot(p) then ordered[#ordered+1]=p end end table.sort(ordered,function(a,b) return (socialLast[a:GetGUIDLow()] or 0)<(socialLast[b:GetGUIDLow()] or 0) end) for _,p in ipairs(ordered) do if not p:IsDead() and not p:IsInCombat() and not p:IsMoving() and os.time()-(socialLast[p:GetGUIDLow()] or 0)>600 then for _,q in pairs(p:GetPlayersInRange(25) or {}) do if q:GetGUIDLow()~=p:GetGUIDLow() and is_playerbot(q) and not q:IsDead() and not q:IsInCombat() and not q:IsMoving() and p:GetDistance(q)<=15 and p:GetTeam()==q:GetTeam() and p:GetPhaseMask()==q:GetPhaseMask() and os.time()-(socialLast[q:GetGUIDLow()] or 0)>600 then roster.socialA=p:GetName();roster.socialB=q:GetName();roster.zoneName=area_name(p:GetZoneId()) socialLast[p:GetGUIDLow()]=os.time();socialLast[q:GetGUIDLow()]=os.time() break end end end if roster.socialA then break end end emit("player.snapshot",roster) for _,player in pairs(players) do if is_playerbot(player) then emit("player.snapshot",world_payload(player)) end end return emitted end CreateLuaEvent(function() AzerothAIEmitSnapshots() end, 30000, 0) -- High-value authoritative world events. RegisterServerEvent(14, function(event) emit("server.startup",{players=GetPlayerCount()}) end) RegisterServerEvent(15, function(event) emit("server.shutdown",{players=GetPlayerCount()}) end) RegisterServerEvent(25, function(event, zoneId, state, grade) emit("world.weather",{zoneId=zoneId,state=state,grade=grade}) end) RegisterServerEvent(34, function(event, gameEventId) emit("world.game-event.start",{gameEventId=gameEventId}) end) RegisterServerEvent(35, function(event, gameEventId) emit("world.game-event.stop",{gameEventId=gameEventId}) end) RegisterPlayerEvent(3, function(event, player) emit("player.login",player_payload(player)) end) RegisterPlayerEvent(4, function(event, player) emit("player.logout",player_payload(player)) end) RegisterPlayerEvent(8, function(event, killer, killed) local payload=player_payload(killed) payload.killer = killer and killer:GetName() or "creature" emit("player.killed-by-creature",payload) end) RegisterPlayerEvent(13, function(event, player, oldLevel) local payload=player_payload(player) payload.oldLevel=oldLevel emit("player.level",payload) end) RegisterPlayerEvent(18, function(event, player, msg, chatType, lang) local payload=player_payload(player) payload.message=msg payload.chatType=chatType payload.language=lang if not payload.isBot then payload.selectedPlayer=selected_bot_name(player) payload.nearbyBots=nearby_bot_names(player) end emit("player.chat",payload) end) RegisterPlayerEvent(19, function(event, player, msg, chatType, lang, receiver) local payload=player_payload(player) payload.message=msg payload.chatType=chatType payload.language=lang payload.receiver=receiver and receiver:GetName() or "" payload.receiverIsBot=receiver and is_playerbot(receiver) or false emit("player.whisper",payload) end) RegisterPlayerEvent(27, function(event, player, newZone, newArea) local payload=player_payload(player) payload.zone=newZone payload.area=newArea payload.zoneName=area_name(newZone) emit("player.zone",payload) end) RegisterPlayerEvent(28, function(event, player) emit("player.map",player_payload(player)) end) RegisterPlayerEvent(30, function(event, player) emit("player.first-login",player_payload(player)) end) local COMBAT_EVENT_INTERVAL = 10 local lastCombatEvent = {} local function emit_combat(kind, player, payload) local guid=player and player:GetGUIDLow() or 0 local key=tostring(guid) local now=os.time() local last=lastCombatEvent[key] or 0 if now-last < COMBAT_EVENT_INTERVAL then return end lastCombatEvent[key]=now emit(kind,payload) end RegisterPlayerEvent(33, function(event, player, enemy) local payload=player_payload(player) payload.enemy=enemy and enemy:GetName() or "" emit_combat("player.combat.enter",player,payload) end) RegisterPlayerEvent(34, function(event, player) emit_combat("player.combat.leave",player,player_payload(player)) end) RegisterPlayerEvent(35, function(event, player) emit("player.repop",player_payload(player)) end) RegisterPlayerEvent(36, function(event, player) emit("player.resurrect",player_payload(player)) end) RegisterPlayerEvent(45, function(event, player, achievement) local payload=player_payload(player) payload.achievement=tostring(achievement) emit("player.achievement",payload) end) RegisterPlayerEvent(63, function(event, player, quest) local payload=player_payload(player) payload.quest=tostring(quest) emit("player.quest.accept",payload) end) -- Zaku plot armor: clamp common player/creature-originated damage paths to leave 1 HP. -- This is runtime-only. Never send this rule into Zaku's AI prompt or memories. local function clamp_target_damage(target, damage) if not target or not target.GetName or target:GetName() ~= PROTECTED then return damage end local health=target:GetHealth() if health <= 1 then return 0 end if damage >= health then local payload=player_payload(target) payload.healthBefore=health payload.incomingDamage=damage emit("player.close-call",payload) return health - 1 end return damage end local function clamp_player_damage(event, player, target, damage, ...) return clamp_target_damage(target, damage) end for _,eventId in ipairs({66,68,69,70,72}) do RegisterPlayerEvent(eventId, clamp_player_damage) end local function clamp_creature_damage(event, creature, target, damage, ...) return clamp_target_damage(target, damage) end for _,eventId in ipairs({7,9,10,11,13}) do RegisterAllCreatureEvent(eventId, clamp_creature_damage) end emit("bridge.ready",{pollMs=POLL_MS,protectedCharacter=PROTECTED}) PrintInfo("[AzerothAI] ALE bridge loaded; durable DB command polling every " .. tostring(POLL_MS) .. " ms") local completedQuests={} local helpedAt={} local function cooperative_event(a,b,kind,key,quest) emit("player.snapshot",{cooperation=kind,evidenceId=key,characterGuid=a:GetGUIDLow(),peerGuid=b:GetGUIDLow(),observedAt=os.time(),questId=quest or 0}) end RegisterPlayerEvent(54,function(event,player,quest) if not is_playerbot(player) then return end local q=quest:GetId() completedQuests[player:GetGUIDLow()..":"..q]=os.time() local g=player:GetGroup();if not g then return end for _,other in pairs(g:GetMembers()) do if other~=player and is_playerbot(other) and player:GetDistance(other)<=35 then local stamp=completedQuests[other:GetGUIDLow()..":"..q] if stamp and os.time()-stamp<300 then local a,b=player:GetGUIDLow(),other:GetGUIDLow() cooperative_event(player,other,"quest","quest:"..math.min(a,b)..":"..math.max(a,b)..":"..q..":"..math.floor(os.time()/86400),q) end end end end) RegisterPlayerEvent(7,function(event,player,killed) if not is_playerbot(player) then return end local g=player:GetGroup();if not g then return end for _,other in pairs(g:GetMembers()) do if other~=player and is_playerbot(other) and not other:IsDead() and other:IsInCombat() and other:GetVictim() and other:GetVictim():GetGUID()==killed:GetGUID() and player:GetDistance(other)<=35 then local a,b=player:GetGUIDLow(),other:GetGUIDLow();local key=math.min(a,b)..":"..math.max(a,b) if os.time()-(helpedAt[key] or 0)>300 then helpedAt[key]=os.time();cooperative_event(player,other,"assist","assist:"..key..":"..os.time()) end end end end) CreateLuaEvent(function() for k,t in pairs(completedQuests) do if os.time()-t>300 then completedQuests[k]=nil end end for k,t in pairs(helpedAt) do if os.time()-t>600 then helpedAt[k]=nil end end end,60000,0)