-- Conservative recovery for managed residents with fresh quest/explore intent. -- Native position/progress is sampled independently of dashboard summaries. local intents, observations, pendingResults = {}, {}, {} local progress={} -- Successful gameplay, rather than merely starting a cast, resets the idle clock. for _,event in ipairs({32,37,54}) do RegisterPlayerEvent(event,function(_,p) if p:IsPlayerbot() then progress[p:GetGUIDLow()]=os.time() end end) end local LIMIT = 600 local COOLDOWN = 1800 local function sqltext(s) return "CONVERT(UNHEX('"..string.gsub(s,".",function(c)return string.format('%02X',string.byte(c))end).."') USING utf8mb4)" end AuthDBExecute([[CREATE TABLE IF NOT EXISTS azerothai_recovery_log ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,guid INT UNSIGNED NOT NULL, name VARCHAR(64) NOT NULL,at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, detail VARCHAR(255) NOT NULL,INDEX(guid,at))]]) AuthDBExecute([[CREATE TABLE IF NOT EXISTS azerothai_recovery_results ( attempt_id BIGINT UNSIGNED PRIMARY KEY,status VARCHAR(24) NOT NULL, checked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,detail VARCHAR(255) NOT NULL)]]) AuthDBExecute([[CREATE TABLE IF NOT EXISTS azerothai_recovery_leases ( guid INT UNSIGNED PRIMARY KEY,name VARCHAR(64),account_id INT UNSIGNED, restore_at BIGINT UNSIGNED,gathering TINYINT,looting TINYINT)]]) local function restoreStrategies(now) local q=AuthDBQuery('SELECT guid,name,account_id,gathering,looting FROM azerothai_recovery_leases WHERE restore_at<='..now) if not q then return end repeat local guid,name,account=q:GetUInt32(0),q:GetString(1),q:GetUInt32(2) local p=GetPlayerByName(name) if p and p:IsPlayerbot() and p:GetGUIDLow()==guid and p:GetAccountId()==account and p:GetBotAI() and not p:GetBotAI():HasGameClientMaster() then if q:GetUInt32(3)==1 then p:GetBotAI():ChangeStrategy('+gather',1) end if q:GetUInt32(4)==1 then p:GetBotAI():ChangeStrategy('+loot',1) end AuthDBExecute('DELETE FROM azerothai_recovery_leases WHERE guid='..guid) end until not q:NextRow() end local function checkResults(now) for guid,r in pairs(pendingResults) do local p=GetPlayerByName(r.name);local status,detail if not p or not p:IsPlayerbot() or p:GetGUIDLow()~=guid or p:GetAccountId()~=r.account or p:IsDead() then status='unverified';detail='Resident unavailable before verification' elseif p:GetMapId()~=r.map or p:GetLevel()~=r.level or (p:GetX()-r.x)^2+(p:GetY()-r.y)^2+(p:GetZ()-r.z)^2>25 then status='resumed';detail='Native movement or level progress observed after recovery' elseif now-r.at>=180 then status='no_progress';detail='No movement or level progress observed within three minutes' end if status then AuthDBExecute('INSERT IGNORE INTO azerothai_recovery_results(attempt_id,status,detail) SELECT id,'..sqltext(status)..','..sqltext(detail)..' FROM azerothai_recovery_log WHERE guid='..guid..' AND UNIX_TIMESTAMP(at)='..r.at..' ORDER BY id DESC LIMIT 1') pendingResults[guid]=nil end end end function AzerothAISetRecoveryIntents(rows) local nextIntents={} for _,r in ipairs(rows) do if type(r.guid)=='number' and type(r.account)=='number' and type(r.name)=='string' and (r.action=='quest' or r.action=='explore') then nextIntents[r.guid]=r;r.at=os.time() end end intents=nextIntents return 'Recovery intentions refreshed' end local function safe(p,a) if AzerothAITrainingOverride and AzerothAITrainingOverride(p) then return false end if AzerothAISurvivalOverride and AzerothAISurvivalOverride(p) then return false end if p:IsDead() or p:IsInCombat() or p:HasUnitState(0x00000100) or a:HasGameClientMaster() or p:IsRooted() or a:IsMovementImpaired() then return false end local g=p:GetGroup() if g then if #g:GetMembers()~=g:GetMembersCount() then return false end for _,m in pairs(g:GetMembers()) do if not m:IsPlayerbot() or not m:GetBotAI() or m:GetBotAI():HasGameClientMaster() then return false end end end return true end local function recover(p,a,r,guid,now,followup) local x,y,z,map,level=p:GetX(),p:GetY(),p:GetZ(),p:GetMapId(),p:GetLevel() local q=AuthDBQuery('SELECT guid FROM azerothai_lifecycle WHERE guid='..guid..' AND account_id='..r.account..' AND BINARY name='..sqltext(r.name).." AND state='active'") local recent=AuthDBQuery('SELECT id FROM azerothai_recovery_log WHERE guid='..guid..' AND at>NOW()-INTERVAL '..COOLDOWN..' SECOND LIMIT 1') if recent and followup then local failed=AuthDBQuery("SELECT l.id FROM azerothai_recovery_log l JOIN azerothai_recovery_results r ON r.attempt_id=l.id WHERE l.guid="..guid.." AND r.status='no_progress' AND l.at>NOW()-INTERVAL 1800 SECOND AND (SELECT COUNT(*) FROM azerothai_recovery_log x WHERE x.guid=l.guid AND x.at>NOW()-INTERVAL 1800 SECOND)=1 LIMIT 1") if failed then recent=nil end end if q and not recent then local interrupted=p:IsCasting() if interrupted then p:StopSpellCast() end local staleFlight=p:GetMovementType()==7 and not p:HasUnitState(0x00000100) if staleFlight then p:MoveExpire();p:MoveClear() end a:Reset(true) local gathering,looting=a:HasStrategy('gather',1),a:HasStrategy('loot',1) if gathering or looting then AuthDBExecute('INSERT INTO azerothai_recovery_leases VALUES ('..guid..','..sqltext(r.name)..','..r.account..','..(now+120)..','..(gathering and 1 or 0)..','..(looting and 1 or 0)..') ON DUPLICATE KEY UPDATE restore_at=VALUES(restore_at),gathering=GREATEST(gathering,VALUES(gathering)),looting=GREATEST(looting,VALUES(looting))') a:ChangeStrategy('-gather',1);a:ChangeStrategy('-loot',1) a:DoSpecificAction('release loot',true) end local moved=a:DoSpecificAction('move random',true) local detail=(interrupted and 'Interrupted non-progressing cast; ' or '')..(staleFlight and 'Cleared stale flight; ' or '')..'AI replanned; gathering paused 120s; walking request='..tostring(moved) AuthDBExecute('INSERT INTO azerothai_recovery_log(guid,name,detail) VALUES ('..guid..','..sqltext(r.name)..','..sqltext(detail)..')') pendingResults[guid]={name=r.name,account=r.account,at=now,x=x,y=y,z=z,map=map,level=level} PrintInfo('[AzerothAI recovery] '..r.name..': '..detail) return true end return false end -- Operator-only entry point for a stall already verified through repeated observations. function AzerothAIRecoverObservedStall(name,guid,account) local p=GetPlayerByName(name) if not p or not p:IsPlayerbot() or p:GetGUIDLow()~=guid or p:GetAccountId()~=account then return 'Identity unavailable' end local a=p:GetBotAI() if not a or not safe(p,a) then return 'Unsafe to recover now' end return recover(p,a,{name=name,account=account},guid,os.time(),true) and 'Recovery requested; awaiting observed progress' or 'Inactive identity or cooldown' end local function sample() local now=os.time() restoreStrategies(now) checkResults(now) for guid,r in pairs(intents) do local p=GetPlayerByName(r.name) if now-r.at>120 or not p or not p:IsPlayerbot() or p:GetGUIDLow()~=guid or p:GetAccountId()~=r.account then observations[guid]=nil else local a=p:GetBotAI() if not a or not safe(p,a) then observations[guid]=nil else local x,y,z,map=p:GetX(),p:GetY(),p:GetZ(),p:GetMapId() local level,hp=p:GetLevel(),p:GetHealth() local old=observations[guid] if not old or old.map~=map or old.level~=level or old.hp~=hp or (progress[guid] or 0)>old.since or (x-old.x)^2+(y-old.y)^2+(z-old.z)^2>25 then observations[guid]={x=x,y=y,z=z,map=map,level=level,hp=hp,since=now} elseif now-old.since>=LIMIT then -- Persistent cooldown survives ALE reloads; exact identity must still be active. recover(p,a,r,guid,now) old.since=now end end end end -- Forget residents whose intent changed to resting/socializing or who went offline. for guid in pairs(observations) do if not intents[guid] then observations[guid]=nil end end for guid,at in pairs(progress) do if now-at>3600 then progress[guid]=nil end end end CreateLuaEvent(function()local ok,err=pcall(sample);if not ok then PrintError('[AzerothAI recovery] '..tostring(err)) end end,30000,0) PrintInfo('[AzerothAI] Conservative stuck recovery enabled: 10 minutes idle, 30 minute cooldown')