448 lines
16 KiB
Lua
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)
|