Fix progression stalls and add normal class trainer visits
Core tests / core (push) Successful in 29s

This commit is contained in:
Codex
2026-09-24 09:24:57 +12:00
parent d57d793a84
commit 132bf75f9d
24 changed files with 343 additions and 31 deletions
+3 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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)
+3 -2
View File
@@ -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)
+16 -7
View File
@@ -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
+131
View File
@@ -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)