-- 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:IsTaxi() 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')