-- Class training uses the core's trainer eligibility and purchase transaction. -- Travel uses ordinary pathfinding. No spell grants, money grants or teleportation. local registry,records,trainers={}, {}, {} local registryAt=0 local strategies={'new rpg','rpg','quest','grind','gather','loot','follow','travel'} local function quote(s)return "UNHEX('"..string.gsub(tostring(s),'.',function(c)return string.format('%02X',string.byte(c))end).."')" end AuthDBQuery([[CREATE TABLE IF NOT EXISTS azerothai_training ( guid INT UNSIGNED PRIMARY KEY,name VARCHAR(64),account_id INT UNSIGNED,checked_level INT, pending INT DEFAULT 0,trainer VARCHAR(100),status VARCHAR(100),at BIGINT UNSIGNED, learned INT DEFAULT 0,last_learned BIGINT UNSIGNED DEFAULT 0, flags VARCHAR(200) DEFAULT '',lease_expires BIGINT UNSIGNED DEFAULT 0)]]) local q=WorldDBQuery([[SELECT t.Requirement,c.id,c.map,c.position_x,c.position_y,c.position_z,ct.name,f.FactionGroup FROM creature c JOIN creature_default_trainer d ON d.CreatureId=c.id JOIN trainer t ON t.Id=d.TrainerId JOIN creature_template ct ON ct.entry=c.id JOIN factiontemplate_dbc f ON f.ID=ct.faction WHERE t.Type=0 AND t.Requirement>0 AND c.map IN (0,1,530,571)]]) if q then repeat trainers[#trainers+1]={class=q:GetUInt32(0),entry=q:GetUInt32(1),map=q:GetUInt32(2),x=q:GetFloat(3),y=q:GetFloat(4),z=q:GetFloat(5),name=q:GetString(6),team=q:GetUInt32(7)} until not q:NextRow() end local function managed(p) local row=p and registry[p:GetGUIDLow()] return row and row.name==p:GetName() and row.account==p:GetAccountId() and p:IsPlayerbot() and p:GetBotAI() and not p:GetBotAI():HasGameClientMaster() end local function safe(p) if not managed(p) or p:IsDead() or p:IsInCombat() or p:IsCasting() or p:HasUnitState(0x00000100) then return false end local group=p:GetGroup() if group then if group:IsRaidGroup() or #group:GetMembers()~=group:GetMembersCount() then return false end for _,m in pairs(group:GetMembers()) do if not managed(m) then return false end end end return not (AzerothAISurvivalOverride and AzerothAISurvivalOverride(p)) end local function save(p,r) AuthDBExecute('INSERT INTO azerothai_training(guid,name,account_id,checked_level,pending,trainer,status,at,learned,last_learned) VALUES ('..p:GetGUIDLow()..','..quote(p:GetName())..','..p:GetAccountId()..','..p:GetLevel()..','..(r.pending or 0)..','..quote(r.target and r.target.name or '')..','..quote(r.status or '')..','..os.time()..','..(r.learned or 0)..','..(r.lastLearned or 0)..') ON DUPLICATE KEY UPDATE name=VALUES(name),account_id=VALUES(account_id),checked_level=VALUES(checked_level),pending=VALUES(pending),trainer=VALUES(trainer),status=VALUES(status),at=VALUES(at),learned=VALUES(learned),last_learned=VALUES(last_learned)') end local function release(p,r) local row=AuthDBQuery('SELECT flags FROM azerothai_training WHERE guid='..p:GetGUIDLow()..' AND account_id='..p:GetAccountId()..' AND name='..quote(p:GetName())) if row then local flags=row:GetString(0) for _,strategy in ipairs(strategies) do if string.find(','..flags..',',','..strategy..',',1,true) then p:GetBotAI():ChangeStrategy('+'..strategy,1) end end AuthDBQuery("UPDATE azerothai_training SET flags='',lease_expires=0 WHERE guid="..p:GetGUIDLow()) end r.holding=false;r.visitSince=nil end local function hold(p,r,now) local ai=p:GetBotAI() if not r.holding then local flags={};for _,s in ipairs(strategies) do if ai:HasStrategy(s,1) then flags[#flags+1]=s end end -- Save synchronously before suppressing work, so a reload cannot strand these flags. AuthDBQuery('INSERT INTO azerothai_training(guid,name,account_id,checked_level,flags,lease_expires) VALUES ('..p:GetGUIDLow()..','..quote(p:GetName())..','..p:GetAccountId()..','..p:GetLevel()..','..quote(table.concat(flags,','))..','..(now+120)..') ON DUPLICATE KEY UPDATE flags=VALUES(flags),lease_expires=VALUES(lease_expires)') r.holding=true;r.leaseSaved=now elseif now-(r.leaseSaved or 0)>=30 then AuthDBExecute('UPDATE azerothai_training SET lease_expires='..(now+120)..' WHERE guid='..p:GetGUIDLow());r.leaseSaved=now end for _,s in ipairs(strategies) do ai:ChangeStrategy('-'..s,1) end ai:ChangeStrategy('-stay',1) end function AzerothAITrainingOverride(p) local r=records[p:GetGUIDLow()];return r and r.holding and r.status=='traveling' or false end function AzerothAITrainingSnapshot(p) local r=records[p:GetGUIDLow()];if not r then return {} end return {trainingPending=r.pending or 0,trainingLevel=r.level or 0,trainingTrainer=r.target and r.target.name or '',trainingStatus=r.status or '',trainingAt=r.at or 0,trainingLearned=r.learned or 0,lastTrainingAt=r.lastLearned or 0} end local function audit(p,r,now) local ai=p:GetBotAI();r.level=p:GetLevel();r.checked=now;r.target=nil;r.pending=0 if not ai.GetClassTrainerStatus then r.status='native trainer API unavailable';return end local best,seen,found=math.huge,{},false local team=p:GetTeam()==0 and 2 or 4 for _,t in ipairs(trainers) do if t.class==p:GetClass() and t.map==p:GetMapId() and math.floor(t.team/team)%2==1 then found=true if not seen[t.entry] then local n,cost=ai:GetClassTrainerStatus(t.entry);seen[t.entry]={n=n,cost=cost} end local need=seen[t.entry];local dist=(p:GetX()-t.x)^2+(p:GetY()-t.y)^2 if need.n>0 and dist6000^2 then r.status='class trainer too far for a local visit' elseif p:GetCoinage()120 then release(p,r) end r.at=now if p:GetLevel()~=r.level then r.retryAt=0 end if not r.checked or p:GetLevel()~=r.level or now-r.checked>=60 then audit(p,r,now) end if not safe(p) then if r.holding then release(p,r) end;save(p,r);return end if r.status~='training due' and r.status~='traveling' then if r.holding then release(p,r) end;save(p,r);return end if now<(r.retryAt or 0) then r.status='visit retry cooldown';save(p,r);return end local t=r.target if not t then return end if not r.visitSince then r.visitSince=now;r.progressAt=now;r.distance=math.huge end local distance=math.sqrt((p:GetX()-t.x)^2+(p:GetY()-t.y)^2) if distance300 or now-r.progressAt>45 then release(p,r);r.status='trainer path needs retry';r.retryAt=now+300;save(p,r);return end hold(p,r,now);r.status='traveling' local npc=p:GetNearestCreature(10,t.entry) if npc and p:GetDistance(npc)<=5 then p:MoveStop() local learned=p:GetBotAI():TrainAtClassTrainer(npc) if learned>0 then r.learned=(r.learned or 0)+learned;r.lastLearned=now end release(p,r);audit(p,r,now);r.retryAt=now+30 else p:MoveTo(7821,t.x,t.y,t.z,true) end save(p,r) end CreateLuaEvent(function() local now=os.time() if now-registryAt>=60 then local rows={};local result=AuthDBQuery("SELECT guid,name,account_id FROM azerothai_lifecycle WHERE state='active'") if result then repeat rows[result:GetUInt32(0)]={name=result:GetString(1),account=result:GetUInt32(2)} until not result:NextRow() end registry=rows;registryAt=now end for _,p in pairs(GetPlayersInWorld() or {}) do if managed(p) then local id=p:GetGUIDLow();local r=records[id] if not r then r={};records[id]=r local row=AuthDBQuery('SELECT learned,last_learned FROM azerothai_training WHERE guid='..id..' AND account_id='..p:GetAccountId()..' AND name='..quote(p:GetName())) if row then r.learned=row:GetUInt32(0);r.lastLearned=row:GetUInt32(1) end release(p,r) end local ok,err=pcall(step,p,r,now);if not ok then PrintError('[AzerothAI training] '..tostring(err));release(p,r) end else local r=records[p:GetGUIDLow()] -- Return our paused strategies when ownership changes; do not continue the visit. if r and r.holding and p:IsPlayerbot() and p:GetBotAI() then release(p,r) end end end for id,r in pairs(records) do if now-(r.at or now)>600 then records[id]=nil end end end,10000,0)