-- 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 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 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=GetAreaName(player:GetZoneId()) or tostring(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(), 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 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",player_payload(player)) return 1 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. function AzerothAIEmitSnapshots() local ok,players=pcall(function() return GetPlayersInWorld() end) if not ok or type(players)~="table" then return 0 end local emitted=0 for _,player in pairs(players) do if is_playerbot(player) then emit("player.snapshot",player_payload(player)) emitted=emitted+1 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=GetAreaName(newZone) or tostring(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")