221 lines
13 KiB
Lua
221 lines
13 KiB
Lua
-- Survival assistance for registered living bots only. No teleport, heal, revive or stat changes.
|
|
local registry,observed,ownedMasters={}, {}, {}
|
|
local registryAt=0
|
|
AuthDBExecute([[CREATE TABLE IF NOT EXISTS azerothai_survival_audit (
|
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
guid INT UNSIGNED,name VARCHAR(64),kind VARCHAR(32),detail VARCHAR(255))]])
|
|
local function quoted(s)return "UNHEX('"..string.gsub(tostring(s),'.',function(c)return string.format('%02X',string.byte(c))end).."')" end
|
|
local function audit(p,kind,detail)
|
|
AuthDBExecute('INSERT INTO azerothai_survival_audit(guid,name,kind,detail) VALUES ('..p:GetGUIDLow()..','..quoted(p:GetName())..','..quoted(kind)..','..quoted(detail)..')')
|
|
end
|
|
-- Persist only strategy flags this guard suppresses; reloads cannot strand a pause.
|
|
local holdStrategies={'rpg','grind','quest','gather','loot'}
|
|
AuthDBExecute([[CREATE TABLE IF NOT EXISTS azerothai_survival_holds (
|
|
guid INT UNSIGNED PRIMARY KEY,name VARCHAR(64),account_id INT UNSIGNED,expires BIGINT UNSIGNED,
|
|
rpg TINYINT,grind TINYINT,quest TINYINT,gather TINYINT,loot TINYINT)]])
|
|
local holdScan=0
|
|
local function restoreHoldRow(q)
|
|
local guid,name,account=q:GetUInt32(0),q:GetString(1),q:GetUInt32(2)
|
|
local p=GetPlayerByName(name)
|
|
if not p or not p:IsPlayerbot() or p:GetGUIDLow()~=guid or p:GetAccountId()~=account or not p:GetBotAI() or p:GetBotAI():HasGameClientMaster() then return end
|
|
for i,key in ipairs(holdStrategies) do if q:GetUInt32(i+2)==1 then p:GetBotAI():ChangeStrategy('+'..key,1) end end
|
|
AuthDBExecute('DELETE FROM azerothai_survival_holds WHERE guid='..guid)
|
|
if observed[guid] then observed[guid].holding=false end
|
|
end
|
|
local function restoreExpiredHolds(now)
|
|
if now-holdScan<10 then return end;holdScan=now
|
|
local q=AuthDBQuery('SELECT guid,name,account_id,rpg,grind,quest,gather,loot FROM azerothai_survival_holds WHERE expires<='..now)
|
|
if q then repeat restoreHoldRow(q) until not q:NextRow() end
|
|
end
|
|
local function holdPlans(p,ai,r,now)
|
|
local flags,changed={},false
|
|
for _,key in ipairs(holdStrategies) do
|
|
local on=ai:HasStrategy(key,1);flags[#flags+1]=on and 1 or 0
|
|
if on then changed=true;ai:ChangeStrategy('-'..key,1) end
|
|
end
|
|
if not r.holding or changed or now-(r.holdSaved or 0)>=30 then
|
|
AuthDBExecute('INSERT INTO azerothai_survival_holds VALUES ('..p:GetGUIDLow()..','..quoted(p:GetName())..','..p:GetAccountId()..','..(now+90)..','..table.concat(flags,',')..') ON DUPLICATE KEY UPDATE expires=VALUES(expires),rpg=GREATEST(rpg,VALUES(rpg)),grind=GREATEST(grind,VALUES(grind)),quest=GREATEST(quest,VALUES(quest)),gather=GREATEST(gather,VALUES(gather)),loot=GREATEST(loot,VALUES(loot))')
|
|
r.holdSaved=now
|
|
end
|
|
r.holding=true
|
|
end
|
|
local function releasePlans(p,r)
|
|
if not r.holding then return end
|
|
local q=AuthDBQuery('SELECT guid,name,account_id,rpg,grind,quest,gather,loot FROM azerothai_survival_holds WHERE guid='..p:GetGUIDLow())
|
|
if q then restoreHoldRow(q) end
|
|
r.holding=false
|
|
end
|
|
local function dangerous(p,enemy)
|
|
if not enemy then return false end
|
|
local creature=enemy:ToCreature()
|
|
local rank=creature and creature:GetRank() or 0
|
|
return enemy:GetLevel()>=p:GetLevel()+2 or rank==1 or rank==2 or rank==3
|
|
end
|
|
local function dangerousSelection(p,r,now)
|
|
local target=p:GetSelection()
|
|
if not target then return false end
|
|
local key=tostring(target:GetGUID())
|
|
if not dangerous(p,target) and not (AzerothAILearnedDanger and AzerothAILearnedDanger(p,target)) and not (r.avoidTargets and (r.avoidTargets[key] or 0)>now) then return false end
|
|
for _,enemy in pairs(p:GetUnfriendlyUnitsInRange(40)) do
|
|
if enemy:GetGUID()==target:GetGUID() then return true end
|
|
end
|
|
return false
|
|
end
|
|
local function outcome(p,r,now)
|
|
local t=r.retreat
|
|
if not t then return end
|
|
local moved=p:GetMapId()~=t.map or (p:GetX()-t.x)^2+(p:GetY()-t.y)^2>=25
|
|
t.moved=t.moved or moved
|
|
local result=p:IsDead() and 'died' or not p:IsInCombat() and 'out-of-combat' or now-t.at>=30 and (t.moved and 'moved-still-in-combat' or 'no-movement')
|
|
if result then audit(p,result,'Retreat observed for '..(now-t.at)..'s; movement='..tostring(t.moved==true));r.retreat=nil end
|
|
end
|
|
local enabled,enabledAt=true,0
|
|
function AzerothAISetSurvivalEnabled(value) enabled=value==true;enabledAt=os.time() end
|
|
local function refresh(now)
|
|
if now-registryAt<60 then return end
|
|
local q=AuthDBQuery("SELECT guid,name,account_id FROM azerothai_lifecycle WHERE state='active'")
|
|
if not q then registry={};return end
|
|
local rows={};repeat rows[q:GetUInt32(0)]={name=q:GetString(1),account=q:GetUInt32(2)} until not q:NextRow()
|
|
registry=rows;registryAt=now
|
|
end
|
|
local function managed(p)
|
|
if not p or not p:IsPlayerbot() or p:IsDead() then return false end
|
|
local r=registry[p:GetGUIDLow()]
|
|
return r and r.name==p:GetName() and r.account==p:GetAccountId()
|
|
end
|
|
local function safeGroup(p)
|
|
local g=p:GetGroup();if not g or g:IsRaidGroup() or #g:GetMembers()~=g:GetMembersCount() then return nil end
|
|
for _,m in pairs(g:GetMembers()) do if not managed(m) or not m:GetBotAI() or m:GetBotAI():HasGameClientMaster() then return nil end end
|
|
return g
|
|
end
|
|
function AzerothAISurvivalState(p)
|
|
local r=observed[p:GetGUIDLow()]
|
|
if not r then return 0,'',0 end
|
|
return r.dangerAt or 0,r.action or '',r.actionAt or 0,r.actionSince or r.actionAt or 0
|
|
end
|
|
-- Prevent slower model intentions from undoing an immediate safety action.
|
|
function AzerothAISurvivalOverride(p)
|
|
local r=observed[p:GetGUIDLow()]
|
|
return r and (r.holding or os.time()-(r.actionAt or 0)<20 and (r.action=='retreat requested' or r.action=='recovering health' or r.action=='regroup requested' or r.action=='waiting for companion' or r.action=='avoiding dangerous target' or r.action=='recovering readiness')) or false
|
|
end
|
|
local function sample()
|
|
local now=os.time();refresh(now);restoreExpiredHolds(now)
|
|
for _,p in pairs(GetPlayersInWorld() or {}) do
|
|
local previous=observed[p:GetGUIDLow()]
|
|
if previous then outcome(p,previous,now) end
|
|
if managed(p) then
|
|
local id=p:GetGUIDLow();local ai=p:GetBotAI();local r=observed[id] or {};observed[id]=r
|
|
local previousAction,previousAt=r.action,r.actionAt or 0
|
|
local hp=p:GetHealthPct()
|
|
local falling=r.hp and r.hp-hp>=20
|
|
local mana=p:GetPowerType()==0 and p:GetPowerPct(0) or 100
|
|
if mana~=mana then mana=100 end
|
|
if p:IsInCombat() and (hp<55 or r.hp and r.hp-hp>=15) then r.dangerAt=now end
|
|
r.hp=hp;r.seen=now
|
|
local master=ai and ai:GetMaster()
|
|
if ownedMasters[id] and (not p:GetGroup() or not master or not master:GetGroup() or master:GetGroup()~=p:GetGroup()) then
|
|
if ai and master and master:IsPlayerbot() and master:GetGUIDLow()==ownedMasters[id] and not ai:HasGameClientMaster() then ai:SetMaster(nil) end
|
|
ownedMasters[id]=nil
|
|
end
|
|
if ai and not ai:HasGameClientMaster() and not p:HasUnitState(0x00000100) and not p:IsRooted() and not ai:IsMovementImpaired() then
|
|
local g=safeGroup(p)
|
|
if (not p:GetGroup() or g) and hp>=95 and mana>=65 and ai:HasStrategy('stay',1) then ai:ChangeStrategy('-stay',1) end
|
|
-- Never intervene in a party containing humans, unregistered bots, or offline members.
|
|
if not p:GetGroup() or g then
|
|
local peers=g and g:GetMembers() or {p};local weak,support=0,0
|
|
for _,m in pairs(peers) do if m:GetMapId()==p:GetMapId() and p:GetDistance(m)<40 and m:GetHealthPct()<40 then weak=weak+1 end end
|
|
for _,m in pairs(peers) do if m~=p and m:GetMapId()==p:GetMapId() and m:GetPhaseMask()==p:GetPhaseMask() and p:GetDistance(m)<=35 and m:GetHealthPct()>=70 then support=support+1 end end
|
|
local attackers=0;for _ in pairs(p:GetAttackers()) do attackers=attackers+1 end
|
|
local enemy=p:GetVictim()
|
|
local overmatched=dangerous(p,enemy) or (AzerothAILearnedDanger and AzerothAILearnedDanger(p,enemy))
|
|
r.avoidTargets=r.avoidTargets or {}
|
|
for key,expires in pairs(r.avoidTargets) do if expires<=now then r.avoidTargets[key]=nil end end
|
|
if enemy and (overmatched or attackers>support+1 or falling and hp<80) then r.avoidTargets[tostring(enemy:GetGUID())]=now+600 end
|
|
local outnumbered=attackers>support+1 and hp<80
|
|
local unsafe=p:IsInCombat() and (hp<(support==0 and 55 or 50) or overmatched or outnumbered or falling and hp<80 or #peers>1 and weak>=math.max(2,math.ceil(#peers/2)))
|
|
local avoid=not p:IsInCombat() and dangerousSelection(p,r,now)
|
|
if unsafe then r.recoverUntil=now+15 end
|
|
if hp<85 or mana<35 then r.resting=true end
|
|
if hp>=95 and mana>=65 then r.resting=false end
|
|
local resting=not p:IsInCombat() and (r.resting or now<(r.recoverUntil or 0))
|
|
-- Decide cohesion before restoring work flags, so native RPG cannot undo a wait/follow.
|
|
local leader=g and ai:GetGroupLeader() or nil
|
|
local regroup,waiting=false,false
|
|
local cohesion=not (AzerothAITrainingOverride and AzerothAITrainingOverride(p)) and enabled and now-enabledAt<180 and g and not p:IsInCombat() and not p:IsCasting() and not unsafe and not avoid and not resting
|
|
if cohesion and leader and managed(leader) and not leader:HasUnitState(0x00000100) then
|
|
if leader~=p and leader:GetMapId()==p:GetMapId() and leader:GetPhaseMask()==p:GetPhaseMask() then
|
|
local distance=p:GetDistance(leader)
|
|
-- Hysteresis prevents alternating independent work and follow at the boundary.
|
|
if distance<=6 or distance>=200 then r.catchingUp=false;r.catchSince=nil;r.catchDistance=nil
|
|
elseif distance>12 and now>=(r.catchBlockedUntil or 0) then
|
|
r.catchingUp=true;r.catchSince=r.catchSince or now
|
|
if not r.catchDistance or distance<r.catchDistance-3 then r.catchProgress=now;r.catchDistance=distance end
|
|
if now-r.catchSince>=120 or now-(r.catchProgress or now)>=30 then
|
|
r.catchingUp=false;r.catchSince=nil;r.catchDistance=nil;r.catchBlockedUntil=now+120
|
|
if ownedMasters[id] and master and master:GetGUIDLow()==ownedMasters[id] then ai:SetMaster(nil);ownedMasters[id]=nil end
|
|
audit(p,'regroup-backoff','No useful reunion within bounded attempt; resume own activities')
|
|
end
|
|
end
|
|
regroup=r.catchingUp and hp>=95 and mana>=65 and not leader:IsInCombat()
|
|
elseif leader==p then
|
|
local separated=false
|
|
for _,m in pairs(peers) do
|
|
if m~=p and not m:HasUnitState(0x00000100) and m:GetMapId()==p:GetMapId() and m:GetPhaseMask()==p:GetPhaseMask() and p:GetDistance(m)<200 and (p:GetDistance(m)>15 or m:GetHealthPct()<85 or (m:GetPowerType()==0 and m:GetPowerPct(0)<35)) then separated=true;break end
|
|
end
|
|
if separated then
|
|
r.waitSince=r.waitSince or now
|
|
waiting=now-r.waitSince<20
|
|
if now-r.waitSince>60 then r.waitSince=now;waiting=true end
|
|
else r.waitSince=nil end
|
|
end
|
|
else r.catchingUp=false;r.waitSince=nil end
|
|
if unsafe or avoid or resting or regroup or waiting then holdPlans(p,ai,r,now) else releasePlans(p,r) end
|
|
if unsafe and now-(r.retreatAt or 0)>=8 then
|
|
r.dangerAt=now;r.retreatAt=now;r.action='retreat requested';r.actionAt=now
|
|
if p:IsCasting() then p:StopSpellCast() end
|
|
r.accepted=ai:DoSpecificAction('flee',true)
|
|
audit(p,'retreat-request', 'accepted='..tostring(r.accepted)..'; hp='..math.floor(hp)..'; dangerous='..tostring(overmatched)..'; attackers='..attackers..'; nearbySupport='..support)
|
|
if not r.retreat then r.retreat={at=now,map=p:GetMapId(),x=p:GetX(),y=p:GetY()} end
|
|
elseif avoid and now-(r.avoidAt or 0)>=8 then
|
|
if p:IsCasting() then p:StopSpellCast() end
|
|
p:AttackStop();r.avoidAt=now;r.dangerAt=now;r.action='avoiding dangerous target';r.actionAt=now
|
|
r.accepted=ai:DoSpecificAction('flee',true)
|
|
audit(p,'avoid-request','accepted='..tostring(r.accepted))
|
|
elseif resting then
|
|
r.action='recovering readiness';r.actionAt=now
|
|
p:AttackStop()
|
|
if not p:IsCasting() then
|
|
p:MoveStop()
|
|
if now-(r.restAt or 0)>=20 then
|
|
r.restAt=now
|
|
ai:DoSpecificAction('eat',true);ai:DoSpecificAction('drink',true);ai:DoSpecificAction('sit',true)
|
|
end
|
|
end
|
|
elseif regroup then
|
|
ai:SetMaster(leader);ownedMasters[id]=leader:GetGUIDLow()
|
|
ai:ChangeStrategy('-stay',1)
|
|
r.action='regroup requested';r.actionAt=now
|
|
if now-(r.followAt or 0)>=8 then
|
|
r.followAt=now;r.accepted=ai:DoSpecificAction('follow',true)
|
|
if now-(r.cohesionAuditAt or 0)>=30 then audit(p,'regroup-request','accepted='..tostring(r.accepted)..'; distance='..math.floor(p:GetDistance(leader)));r.cohesionAuditAt=now end
|
|
end
|
|
elseif waiting then
|
|
p:MoveStop();r.action='waiting for companion';r.actionAt=now
|
|
|
|
end
|
|
end
|
|
end
|
|
if r.actionAt and (r.action~=previousAction or now-previousAt>30) then r.actionSince=now end
|
|
end
|
|
end
|
|
for id,r in pairs(observed) do if now-(r.seen or 0)>600 then observed[id]=nil;ownedMasters[id]=nil end end
|
|
end
|
|
CreateLuaEvent(function()local ok,err=pcall(sample);if not ok then PrintError('[AzerothAI survival] '..tostring(err)) end end,2000,0)
|
|
PrintInfo('[AzerothAI] Survival assistance enabled: danger memory, native retreat and nearby regrouping')
|
|
|
|
CreateLuaEvent(function() AuthDBExecute("DELETE FROM azerothai_survival_audit WHERE at<NOW()-INTERVAL 7 DAY LIMIT 2000") end,3600000,0)
|
|
|
|
local function deathResult(_,killer,p) local r=observed[p:GetGUIDLow()];if r and r.retreat then audit(p,'died','Died after retreat request');r.retreat=nil end end
|
|
RegisterPlayerEvent(8,deathResult)
|
|
RegisterPlayerEvent(6,deathResult)
|