207 lines
8.7 KiB
Lua
207 lines
8.7 KiB
Lua
-- Persistent hardcore retirement. The native random-bot manager fills vacancies
|
|
-- from its factory-created, unused starting-level reserve; identities are never reset.
|
|
-- Install only after deploy/playable/install-lifecycle.py has seeded the registry.
|
|
local residents = {}
|
|
local ready = false
|
|
local vetoAt = {}
|
|
local unmanaged = {}
|
|
local TARGET_PER_FACTION = 25
|
|
local gates = {}
|
|
local gateAt = {}
|
|
local arrivals = {}
|
|
local reconciledAt = 0
|
|
local alliance = {[1]=true,[3]=true,[4]=true,[7]=true,[11]=true}
|
|
local horde = {[2]=true,[5]=true,[6]=true,[8]=true,[10]=true}
|
|
local function faction(row)
|
|
if alliance[row.race] then return 1 end
|
|
if horde[row.race] then return 2 end
|
|
return nil
|
|
end
|
|
|
|
local function sqltext(value)
|
|
return "CONVERT(UNHEX('"..(string.gsub(value,".",function(c) return string.format("%02X",string.byte(c)) end)).."') USING utf8mb4)"
|
|
end
|
|
|
|
local function startingLevel(player)
|
|
return player:GetClass()==6 and 55 or 1
|
|
end
|
|
|
|
local function enroll(player)
|
|
local guid=player:GetGUIDLow()
|
|
if unmanaged[guid] then return nil end
|
|
local account=player:GetAccountId()
|
|
local name=player:GetName()
|
|
if string.lower(name)=="zaku" then return nil end
|
|
-- The native factory can create additional accounts during startup. Accept
|
|
-- only its accounts and only genuinely unused characters as new residents.
|
|
local owned=AuthDBQuery("SELECT id FROM account WHERE id="..account.." AND username REGEXP '^RNDBOT[0-9]+$'")
|
|
if not owned then unmanaged[guid]=true;return nil end
|
|
local character=CharDBQuery("SELECT totaltime FROM characters WHERE guid="..guid.." AND account="..account)
|
|
if not character then return nil end
|
|
local state=(player:GetLevel()==startingLevel(player) and character:GetUInt32(0)<=60) and "reserve" or "blocked"
|
|
local row={name=name,account=account,protected=false,state=state,race=player:GetRace()}
|
|
residents[guid]=row
|
|
AuthDBExecute("INSERT IGNORE INTO azerothai_lifecycle(guid,name,account_id,protected,state,race) VALUES ("..
|
|
guid..","..sqltext(name)..","..account..",0,"..sqltext(state)..","..row.race..")")
|
|
return row
|
|
end
|
|
|
|
local function reload()
|
|
local q=AuthDBQuery("SELECT guid,name,account_id,protected,state,race FROM azerothai_lifecycle")
|
|
if not q then error("AzerothAI lifecycle registry is unavailable or empty") end
|
|
local nextResidents={}
|
|
repeat
|
|
nextResidents[q:GetUInt32(0)]={name=q:GetString(1),account=q:GetUInt32(2),
|
|
protected=q:GetUInt32(3)==1,state=q:GetString(4),race=q:GetUInt32(5)}
|
|
until not q:NextRow()
|
|
residents=nextResidents
|
|
ready=true
|
|
end
|
|
|
|
local function identity(player)
|
|
if not ready or not player then return nil end
|
|
local guid=player:GetGUIDLow()
|
|
local row=residents[guid] or enroll(player)
|
|
if not row or row.account~=player:GetAccountId() or row.name~=player:GetName() then return nil end
|
|
if row.protected or string.lower(row.name)=="zaku" then return nil end
|
|
return row,guid
|
|
end
|
|
|
|
local function recordDeath(player)
|
|
local row,guid=identity(player)
|
|
if not row or row.state=="dead" or row.state=="blocked" then return end
|
|
row.state="dead"
|
|
AuthDBExecute("UPDATE azerothai_lifecycle SET state='dead',died_at=COALESCE(died_at,NOW()) WHERE guid="..
|
|
guid.." AND protected=0 AND LOWER(name)<>'zaku'")
|
|
if AzerothAIEmitPermanentDeath then AzerothAIEmitPermanentDeath(player) end
|
|
end
|
|
|
|
local function enforce()
|
|
if not ready then reload() end
|
|
-- The dashboard may receive a verified death from an older transport backlog
|
|
-- after this process seeded its registry. Import terminal states only; never
|
|
-- overwrite a locally observed death with an older active database row.
|
|
if os.time()-reconciledAt>=10 then
|
|
local q=AuthDBQuery("SELECT guid,name,account_id FROM azerothai_lifecycle WHERE state='dead' AND protected=0 AND LOWER(name)<>'zaku'")
|
|
if q then repeat
|
|
local row=residents[q:GetUInt32(0)]
|
|
if row and not row.protected and row.name==q:GetString(1) and row.account==q:GetUInt32(2) then row.state="dead" end
|
|
until not q:NextRow() end
|
|
reconciledAt=os.time()
|
|
end
|
|
local mgr=GetRandomPlayerbotMgr()
|
|
-- Disallow every retired identity before the native manager fills vacancies.
|
|
for guid,row in pairs(residents) do
|
|
if (row.state=="dead" or row.state=="blocked") and not row.protected and string.lower(row.name)~="zaku" then
|
|
local player=GetPlayerByName(row.name)
|
|
local removed=false
|
|
if player and player:GetGUIDLow()==guid and player:GetAccountId()==row.account then
|
|
mgr:Remove(player)
|
|
removed=true
|
|
end
|
|
-- Remove clears the manager's event cache, so set the login veto afterward.
|
|
if removed or not vetoAt[guid] or os.time()-vetoAt[guid]>300 then
|
|
mgr:SetValue(guid,"add",0)
|
|
mgr:SetValue(guid,"logout",1,"AzerothAI permanent retirement")
|
|
vetoAt[guid]=os.time()
|
|
end
|
|
end
|
|
end
|
|
local players=GetPlayersInWorld() or {}
|
|
local counts={0,0}
|
|
local online={}
|
|
local returning={}
|
|
for _,player in pairs(players) do
|
|
local guid=player:GetGUIDLow()
|
|
local row=residents[guid]
|
|
if row and row.state=="active" and row.account==player:GetAccountId() and row.name==player:GetName() and not player:IsDead() then
|
|
returning[#returning+1]={player=player,row=row,guid=guid}
|
|
end
|
|
end
|
|
table.sort(returning,function(a,b)
|
|
local ap=a.row.protected or string.lower(a.row.name)=="zaku"
|
|
local bp=b.row.protected or string.lower(b.row.name)=="zaku"
|
|
if ap~=bp then return ap end
|
|
return a.guid<b.guid
|
|
end)
|
|
for _,item in ipairs(returning) do
|
|
local team=faction(item.row)
|
|
if team then
|
|
if counts[team]<TARGET_PER_FACTION or item.row.protected or string.lower(item.row.name)=="zaku" then
|
|
counts[team]=counts[team]+1
|
|
online[item.guid]=true
|
|
else
|
|
-- Returning residents keep their identity and progress while held offline.
|
|
mgr:Remove(item.player)
|
|
mgr:SetValue(item.guid,"logout",1,"AzerothAI faction capacity")
|
|
gates[item.guid]=false
|
|
gateAt[item.guid]=os.time()
|
|
end
|
|
end
|
|
end
|
|
players=GetPlayersInWorld() or {}
|
|
for _,player in pairs(players) do
|
|
local row,guid=identity(player)
|
|
if row and row.state~="dead" and row.state~="blocked" then
|
|
if player:IsDead() then
|
|
recordDeath(player)
|
|
elseif row.state=="reserve" then
|
|
if (arrivals[guid] or player:GetLevel())~=startingLevel(player) then
|
|
row.state="blocked"
|
|
AuthDBExecute("UPDATE azerothai_lifecycle SET state='blocked' WHERE guid="..guid.." AND state='reserve'")
|
|
PrintError("[AzerothAI] Replacement did not start at its class starting level: "..row.name)
|
|
elseif faction(row) and counts[faction(row)]<TARGET_PER_FACTION then
|
|
counts[faction(row)]=counts[faction(row)]+1
|
|
row.state="active"
|
|
online[guid]=true
|
|
AuthDBExecute("UPDATE azerothai_lifecycle SET state='active',entry_level="..startingLevel(player)..",activated_at=COALESCE(activated_at,NOW()) WHERE guid="..guid.." AND state='reserve'")
|
|
if AzerothAIEmitPlayerSnapshot then AzerothAIEmitPlayerSnapshot(row.name) end
|
|
PrintInfo("[AzerothAI] New resident entered at class starting level: "..row.name)
|
|
else
|
|
-- A login already in flight must not push its faction over the target.
|
|
mgr:Remove(player)
|
|
mgr:SetValue(guid,"logout",1,"AzerothAI faction capacity")
|
|
gates[guid]=false
|
|
gateAt[guid]=os.time()
|
|
end
|
|
end
|
|
end
|
|
end
|
|
-- Open only enough fresh identities to fill each faction's actual deficit.
|
|
-- Persisted login vetoes keep the native random selector inside these slots.
|
|
local slots={math.max(0,TARGET_PER_FACTION-counts[1]),math.max(0,TARGET_PER_FACTION-counts[2])}
|
|
local candidates={}
|
|
for guid,row in pairs(residents) do if (row.state=="reserve" or row.state=="active") and not online[guid] and not row.protected and string.lower(row.name)~="zaku" then candidates[#candidates+1]=guid end end
|
|
table.sort(candidates,function(a,b)
|
|
if residents[a].state~=residents[b].state then return residents[a].state=="active" end
|
|
return a<b
|
|
end)
|
|
for _,guid in ipairs(candidates) do
|
|
local team=faction(residents[guid])
|
|
local allowed=team~=nil and slots[team]>0
|
|
if allowed then slots[team]=slots[team]-1 end
|
|
if gates[guid]~=allowed or not gateAt[guid] or os.time()-gateAt[guid]>300 then
|
|
mgr:SetValue(guid,"logout",allowed and 0 or 1,"AzerothAI faction capacity")
|
|
gates[guid]=allowed
|
|
gateAt[guid]=os.time()
|
|
end
|
|
if not allowed and mgr:GetValue(guid,"add")~=0 then mgr:SetValue(guid,"add",0) end
|
|
end
|
|
end
|
|
|
|
-- Record death synchronously in memory; logout happens outside the death hook.
|
|
RegisterPlayerEvent(8,function(event,killer,killed) recordDeath(killed) end)
|
|
RegisterPlayerEvent(6,function(event,killer,killed) recordDeath(killed) end)
|
|
RegisterPlayerEvent(3,function(event,player)
|
|
local row,guid=identity(player)
|
|
if row and row.state=="reserve" then arrivals[guid]=player:GetLevel() end
|
|
end)
|
|
CreateLuaEvent(function()
|
|
local ok,err=pcall(enforce)
|
|
if not ok then PrintError("[AzerothAI] Lifecycle enforcement failed: "..tostring(err)) end
|
|
end,1000,0)
|
|
|
|
reload()
|
|
enforce()
|
|
PrintInfo("[AzerothAI] Persistent hardcore retirement and reserve replacement enabled")
|