Files
AzerothAI/ale/azerothai_parties.lua
T
2026-09-24 09:24:57 +12:00

106 lines
6.0 KiB
Lua

-- Native bounded invitation retries and verified-death membership cleanup.
local registry,registryAt,pending={},0,{}
local function text(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_party_audit (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
kind VARCHAR(32),resident VARCHAR(64),peer VARCHAR(64),detail VARCHAR(255))]])
local function audit(kind,a,b,detail)
AuthDBExecute('INSERT INTO azerothai_party_audit(kind,resident,peer,detail) VALUES ('..text(kind)..','..text(a)..','..text(b)..','..text(detail)..')')
end
local function refresh()
if os.time()-registryAt<5 then return end
local q=AuthDBQuery('SELECT guid,name,account_id,state FROM azerothai_lifecycle');local rows={}
if q then repeat rows[q:GetUInt32(0)]={name=q:GetString(1),account=q:GetUInt32(2),state=q:GetString(3)} until not q:NextRow() end
registry=rows;registryAt=os.time()
end
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: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
if not m:IsPlayerbot() or not m:GetBotAI() or m:GetBotAI():HasGameClientMaster() or m:IsInCombat() then return end
end
-- Validate the complete persisted roster, including offline members, before changing it.
local q=CharDBQuery('SELECT c.guid,c.name,c.account FROM group_member gm JOIN characters c ON c.guid=gm.memberGuid WHERE gm.guid=(SELECT guid FROM group_member WHERE memberGuid='..p:GetGUIDLow()..')')
if not q then return end
local count,dead=0,{}
repeat
local id,name,account=q:GetUInt32(0),q:GetString(1),q:GetUInt32(2);local r=registry[id]
if not r or r.name~=name or r.account~=account or (r.state~='active' and r.state~='dead') then return end
if r.state=='dead' then dead[#dead+1]={id=id,name=name}
else
local live=GetPlayerByName(name)
if not resident(live) or live:GetGUIDLow()~=id or live:GetGroup()~=g then return end
end
count=count+1
until not q:NextRow()
if count~=g:GetMembersCount() then return end
-- Removing from a two-member group can destroy the Group object: stop after one mutation.
local d=dead[1]
if d then
local guid=g:GetMemberGUID(d.name)
if GetGUIDLow(guid)~=d.id then return end
if g:RemoveMember(guid,0) then
audit('retired-member-removed',p:GetName(),d.name,'Removed verified permanent-death membership; living members preserved')
if AzerothAIEmitSnapshots then AzerothAIEmitSnapshots() end
end
end
end
local function attempt(r)
local a,b=GetPlayerByName(r.a.name),GetPlayerByName(r.b.name)
if not a or not b then return false,'Resident offline' end
if a:GetGUIDLow()~=r.a.guid or a:GetAccountId()~=r.a.account or b:GetGUIDLow()~=r.b.guid or b:GetAccountId()~=r.b.account then return nil,'Identity changed' end
if not resident(a) or not resident(b) then return nil,'Resident retired or human-controlled' end
if a:GetTeam()~=b:GetTeam() or math.abs(a:GetLevel()-b:GetLevel())>5 then return nil,'Faction or level mismatch' end
if not available(a) or not available(b) then return false,'Waiting for combat, cast, or taxi to finish' end
if a:GetMapId()~=b:GetMapId() or a:GetPhaseMask()~=b:GetPhaseMask() or a:GetDistance(b)>60 then return false,'Waiting for a nearby meeting within 60 yards' end
local g=a:GetGroup()
if g and b:GetGroup()==g then return true,'Already grouped' end
if b:GetGroup() then return nil,'Companion joined another party' end
if g then
if g:IsRaidGroup() or g:GetMembersCount()>=5 then return nil,'Party full or raid' end
if #g:GetMembers()~=g:GetMembersCount() then return false,'Waiting for incomplete party cleanup' end
for _,m in pairs(g:GetMembers()) do if not resident(m) then return nil,'Human or unmanaged party is protected' end end
if not g:AddMember(b) then return nil,'Native party join rejected' end
else g=a:GroupCreate(b) end
if not g or b:GetGroup()~=g then return nil,'Party membership was not applied' end
return true,'Confirmed party'
end
function AzerothAIQueueParty(id,a,b,expires)
if pending[id] then return {azerothaiDeferred=true} end
if type(id)~='string' or type(expires)~='number' or expires<=os.time() or expires>os.time()+60 then error('Invitation expired before delivery') end
for _,r in pairs(pending) do if r.a.guid==a.guid or r.b.guid==a.guid or r.a.guid==b.guid or r.b.guid==b.guid then error('Resident already has a pending invitation') end end
pending[id]={a=a,b=b,expires=expires}
return {azerothaiDeferred=true}
end
local function pulse()
refresh()
for id,r in pairs(pending) do
local ran,ok,reason
if os.time()>=r.expires then ran=true;ok=false;reason=r.reason or 'No safe nearby meeting' else ran,ok,reason=pcall(attempt,r) end
r.reason=reason
if not ran then reason=tostring(ok);ok=nil end
if ok or ok==nil or os.time()>=r.expires then
pending[id]=nil
local result=ok and reason or (ok==nil and reason or 'Invitation expired: '..reason)
audit(ok and 'invitation-completed' or 'invitation-failed',r.a.name,r.b.name,result)
AzerothAIComplete(id,ok==true,result)
if AzerothAIEmitSnapshots then AzerothAIEmitSnapshots() end
end
end
end
CreateLuaEvent(function()local ok,e=pcall(pulse);if not ok then PrintError('[AzerothAI parties] '..tostring(e)) end end,2000,0)
CreateLuaEvent(function()
refresh();local seen={}
for _,p in pairs(GetPlayersInWorld() or {}) do
if available(p) and p:GetGroup() then
local id=tostring(p:GetGroup():GetGUID())
if not seen[id] then seen[id]=true;local ok,e=pcall(cleanGroup,p);if not ok then PrintError('[AzerothAI parties] '..tostring(e)) end end
end
end
end,10000,0)
PrintInfo('[AzerothAI] Bounded native invitations and verified-death party cleanup enabled')