Files
AzerothAI/ale/azerothai_bridge.lua
T
2026-09-24 08:05:16 +12:00

448 lines
16 KiB
Lua

-- 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(),
inCombat=player:IsInCombat(),casting=player:IsCasting(),taxi=player:IsTaxi(),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
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 AzerothAIReadinessSnapshot then for k,v in pairs(AzerothAIReadinessSnapshot(player)) do p[k]=v end 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)