Fix progression stalls and add normal class trainer visits
Core tests / core (push) Successful in 29s
Core tests / core (push) Successful in 29s
This commit is contained in:
@@ -114,7 +114,8 @@ local function player_payload(player)
|
||||
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(),
|
||||
-- UNIT_STATE_IN_FLIGHT: IsTaxi() identifies flight-master NPCs, not flying players.
|
||||
inCombat=player:IsInCombat(),casting=player:IsCasting(),taxi=player:HasUnitState(0x00000100),movementType=player:GetMovementType(),
|
||||
isBot=isBot
|
||||
}
|
||||
end
|
||||
@@ -159,6 +160,7 @@ local function world_payload(player)
|
||||
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 AzerothAITrainingSnapshot then for k,v in pairs(AzerothAITrainingSnapshot(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
|
||||
|
||||
@@ -17,7 +17,7 @@ local function resident(p)
|
||||
local r=p and registry[p:GetGUIDLow()]
|
||||
return p and p:IsPlayerbot() and r and r.state=='active' and r.name==p:GetName() and r.account==p:GetAccountId() and not p:IsDead() and p:GetBotAI() and not p:GetBotAI():HasGameClientMaster()
|
||||
end
|
||||
local function available(p)return resident(p) and not p:IsInCombat() and not p:IsCasting() and not p:IsTaxi() end
|
||||
local function available(p)return resident(p) and not p:IsInCombat() and not p:IsCasting() and not p:HasUnitState(0x00000100) end
|
||||
local function cleanGroup(p)
|
||||
local g=p:GetGroup();if not g or g:IsRaidGroup() then return end
|
||||
local members=g:GetMembers();for _,m in pairs(members) do
|
||||
|
||||
@@ -13,12 +13,13 @@ local function managed(p)
|
||||
return r and p:IsPlayerbot() and r.name==p:GetName() and r.account==p:GetAccountId() 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:IsTaxi() then return false end
|
||||
if not managed(p) or p:IsDead() or p:IsInCombat() or p:IsCasting() or p:HasUnitState(0x00000100) then return false end
|
||||
local g=p:GetGroup()
|
||||
if g then
|
||||
if g:IsRaidGroup() or #g:GetMembers()~=g:GetMembersCount() then return false end
|
||||
for _,m in pairs(g:GetMembers()) do if not managed(m) then return false end end
|
||||
end
|
||||
if AzerothAITrainingOverride and AzerothAITrainingOverride(p) then return false end
|
||||
return not (AzerothAISurvivalOverride and AzerothAISurvivalOverride(p))
|
||||
end
|
||||
function AzerothAIReadinessSnapshot(p)
|
||||
|
||||
@@ -60,8 +60,9 @@ function AzerothAISetRecoveryIntents(rows)
|
||||
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:IsTaxi() or a:HasGameClientMaster()
|
||||
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
|
||||
@@ -81,7 +82,7 @@ local function recover(p,a,r,guid,now,followup)
|
||||
if q and not recent then
|
||||
local interrupted=p:IsCasting()
|
||||
if interrupted then p:StopSpellCast() end
|
||||
local staleFlight=p:GetMovementType()==7 and not p:IsTaxi()
|
||||
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)
|
||||
|
||||
@@ -117,8 +117,9 @@ local function sample()
|
||||
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:IsTaxi() and not p:IsRooted() and not ai:IsMovementImpaired() then
|
||||
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
|
||||
@@ -140,18 +141,26 @@ local function sample()
|
||||
-- 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=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:IsTaxi() then
|
||||
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
|
||||
elseif distance>12 then r.catchingUp=true end
|
||||
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:IsTaxi() 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
|
||||
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
|
||||
@@ -179,7 +188,7 @@ local function sample()
|
||||
p:MoveStop()
|
||||
if now-(r.restAt or 0)>=20 then
|
||||
r.restAt=now
|
||||
ai:DoSpecificAction('eat',true);ai:DoSpecificAction('drink',true)
|
||||
ai:DoSpecificAction('eat',true);ai:DoSpecificAction('drink',true);ai:DoSpecificAction('sit',true)
|
||||
end
|
||||
end
|
||||
elseif regroup then
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
-- 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 dist<best then best=dist;r.target=t;r.pending=need.n;r.cost=need.cost end
|
||||
end
|
||||
end
|
||||
if not found then r.status='no class trainer on this map'
|
||||
elseif not r.target then r.status='no new trainable spells'
|
||||
elseif best>6000^2 then r.status='class trainer too far for a local visit'
|
||||
elseif p:GetCoinage()<r.cost then r.status='saving for training'
|
||||
else r.status='training due' end
|
||||
end
|
||||
local function step(p,r,now)
|
||||
if r.holding and now-(r.at or now)>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 distance<r.distance-3 then r.progressAt=now;r.distance=distance end
|
||||
if now-r.visitSince>300 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)
|
||||
Reference in New Issue
Block a user