diff --git a/ale/azerothai_parties.lua b/ale/azerothai_parties.lua new file mode 100644 index 0000000..5e9516d --- /dev/null +++ b/ale/azerothai_parties.lua @@ -0,0 +1,105 @@ +-- 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())>10 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') diff --git a/core/ale.ts b/core/ale.ts index c6b621b..6ab18e4 100644 --- a/core/ale.ts +++ b/core/ale.ts @@ -66,7 +66,9 @@ end local __ok,__result=pcall(function() ${source} end) -AzerothAIComplete(__command_id,__ok,__stringify(__result)) +if not (__ok and type(__result)=="table" and __result.azerothaiDeferred==true) then + AzerothAIComplete(__command_id,__ok,__stringify(__result)) +end `; } diff --git a/core/community.ts b/core/community.ts index 196b2bd..f4b13c7 100644 --- a/core/community.ts +++ b/core/community.ts @@ -60,14 +60,18 @@ export function partyDecision(a:Bot,b:Bot){ return {accepted:willing(a,b)&&willing(b,a),reason:willing(a,b)&&willing(b,a)?danger?'Seeking safety together after recent danger; compatible faction, levels, and non-hostile companions.':'Both accepted: compatible levels, shared adventure interests, and no hostile bond.':'One character prefers to adventure alone or does not trust the other.'}; } export function compileCommunityAction(kind:'party'|'leave'|'recruit',a:Bot,b?:Bot){ + if(kind==='party'){ + if(!b||!a.autonomy?.accountId||!b.autonomy?.accountId)throw new Error('Verified native accounts required'); + const identity=(p:Bot)=>`{name=${luaString(p.name)},guid=${p.autonomy!.characterGuid},account=${p.autonomy!.accountId}}`; + return `if not AzerothAIQueueParty then error("Native invitation guard unavailable") end; return AzerothAIQueueParty(__command_id,${identity(a)},${identity(b)},${Math.floor(Date.now()/1000)+45})`; + } const lines=[`local a=GetPlayerByName(${luaString(a.name)})`,`if not a or a:GetGUIDLow()~=${a.autonomy!.characterGuid} or not a:IsPlayerbot() or a:IsDead() or a:IsInCombat() or a:IsTaxi() or a:IsCasting() or a:GetBotAI():HasGameClientMaster() then error('Resident unavailable') end`]; if(kind==='leave'){ lines.push(`local g=a:GetGroup();if not g then return 'Already solo' end`,`if tostring(g:GetGUID())~=${luaString(a.world!.groupId)} then error('Party changed') end`,`if #g:GetMembers()~=g:GetMembersCount() then error('Party has offline members') end;for _,m in pairs(g:GetMembers()) do if not m:IsPlayerbot() or m:GetBotAI():HasGameClientMaster() then error('Human party is protected') end end`,`a:RemoveFromGroup()`,`if a:GetGroup() then error('Departure was not applied') end`); }else{ if(!b)throw new Error('Peer required'); lines.push(`local b=GetPlayerByName(${luaString(b.name)})`,`if not b or b:GetGUIDLow()~=${b.autonomy!.characterGuid} or not b:IsPlayerbot() or b:IsDead() or b:IsInCombat() or b:IsTaxi() or b:IsCasting() or b:GetBotAI():HasGameClientMaster() or a:GetTeam()~=b:GetTeam() or a:GetMapId()~=b:GetMapId() or a:GetPhaseMask()~=b:GetPhaseMask() or a:GetDistance(b)>35 then error('Peer unavailable nearby') end`); - if(kind==='party')lines.push(`if math.abs(a:GetLevel()-b:GetLevel())>10 then error('Level mismatch') end`,`local g=a:GetGroup();if g and b:GetGroup()==g then return 'Already grouped' end`,`if b:GetGroup() then error('Peer already grouped') end`,`if g then if g:IsRaidGroup() or g:GetMembersCount()>=5 then error('Party full') end;if #g:GetMembers()~=g:GetMembersCount() then error('Party has offline members') end;for _,m in pairs(g:GetMembers()) do if not m:IsPlayerbot() or m:GetBotAI():HasGameClientMaster() then error('Human party is protected') end end;if not g:AddMember(b) then error('Party join failed') end else g=a:GroupCreate(b) end`,`if not g or b:GetGroup()~=g then error('Party membership was not applied') end`); - else lines.push(`local g=a:GetGuild();if not g or a:GetGuildId()~=${a.world!.guildId} or a:GetGuildRank()~=0 then error('Guild leader unavailable') end`,`if b:GetGuildId()==a:GetGuildId() then return 'Already a member' end`,`if b:GetGuildId()~=0 then error('Already belongs to a guild') end`,`g:AddMember(b)`,`if b:GetGuildId()~=a:GetGuildId() then error('Guild admission failed') end`); + lines.push(`local g=a:GetGuild();if not g or a:GetGuildId()~=${a.world!.guildId} or a:GetGuildRank()~=0 then error('Guild leader unavailable') end`,`if b:GetGuildId()==a:GetGuildId() then return 'Already a member' end`,`if b:GetGuildId()~=0 then error('Already belongs to a guild') end`,`g:AddMember(b)`,`if b:GetGuildId()~=a:GetGuildId() then error('Guild admission failed') end`); } lines.push('AzerothAIEmitSnapshots()','return "Confirmed '+kind+'"');return lines.join('; '); } @@ -97,7 +101,7 @@ export async function communityTick(store:StateStore,db:D1Database){ await db.prepare("DELETE FROM community_actions WHERE status!='pending' AND created_atfresh(b)).sort((a,b)=>Number(recentDanger(b))-Number(recentDanger(a))); const recent=await db.prepare('SELECT a,b,created_at,status FROM community_actions WHERE created_at>?').bind(new Date(now-10*60_000).toISOString()).all(); - const busy=new Set(recent.results.filter(r=>r.status==='pending'||now-Date.parse(r.created_at)<(r.status==='completed'?120_000:60_000)).flatMap(r=>[r.a,r.b])); + const busy=new Set([...pending.results.flatMap(r=>[r.a,r.b]),...recent.results.filter(r=>r.status==='pending'||now-Date.parse(r.created_at)<(r.status==='completed'?120_000:60_000)).flatMap(r=>[r.a,r.b])]); for(const a of bots){ if(busy.has(a.id))continue; // Leave only a bot-only party whose peers have moved away, never a human party. diff --git a/docs/survival-cooperation.md b/docs/survival-cooperation.md index 8b81314..346b70f 100644 --- a/docs/survival-cooperation.md +++ b/docs/survival-cooperation.md @@ -11,3 +11,11 @@ Healthy, noncombat followers 18–90 yards from their bot leader on the same map Grouping assistance uses the existing parties-enabled setting, refreshed by the operations collector with a three-minute expiry. Danger detection and emergency self-preservation remain active for eligible bots. Bot Activity displays recent survival requests explicitly as requests. The ALE bridge supplies native combat role and up to 25 active quest IDs for companion matching. Install `ale/azerothai_survival.lua` beside the existing bridge. Its registry refreshes every minute. It uses no model calls and its per-character runtime observations expire after ten minutes offline. Game hooks and policy tests exercise retreat cooldown, follow distance, faction/hostility and human-control exclusions. In-game survival improvement must be evaluated over actual play; this is not a promise that bots cannot die. + +## Invitation and membership repair + +Install `ale/azerothai_parties.lua` beside the bridge and reload ALE before deploying the matching runtime. Party commands fail closed if this native guard is unavailable. + +Party invitations now carry both exact native identities and expire 45 seconds after creation. The native guard retries every two seconds while a participant is busy or out of range, allowing a meeting within 60 yards on the same map/phase. Every attempt rechecks identity, lifecycle, faction, levels, human control and group capacity. There is no teleport or forced combat interruption. A deferred bridge command receives its completion receipt only when membership is confirmed or the invitation expires/fails. Pending participants remain reserved so another invitation cannot race the first. ALE reloads lose in-memory retries; the durable lane eventually replays the expired request as a failure rather than applying an old invitation. + +Every ten seconds, the party guard checks incomplete groups against the complete persisted character membership and hardcore registry. It removes only a verified permanently dead member, after validating every identity and protecting human, unknown, active-but-offline, raid and combat parties. One mutation per group avoids touching a Group object after native disbanding. The audit table `acore_auth.azerothai_party_audit` records actual removals and invitation outcomes; it is covered by the full auth database backup. diff --git a/tests/community.test.cjs b/tests/community.test.cjs index 234617a..fca5737 100644 --- a/tests/community.test.cjs +++ b/tests/community.test.cjs @@ -2,7 +2,7 @@ const {test}=require('node:test'); const assert=require('node:assert/strict'); const {partyDecision,bondEvidence,community,compileCommunityAction,observePresence,applyCooperation}=require('../.test-build/community.js'); const now=()=>Date.now()/1000; -function bot(id){return {id,name:id,status:'online',level:5,autonomy:{managed:true,characterGuid:id==='A'?1:2},personality:{sociability:60,activities:['quest']},relationships:[],memories:[],world:{at:now(),team:1,nearby:id==='A'?['B']:['A'],inCombat:false,groupNames:[],groupId:'',guildId:3,guildName:'Guild',guildRank:0,mapId:0,x:1,y:1}};} +function bot(id){return {id,name:id,status:'online',level:5,autonomy:{managed:true,characterGuid:id==='A'?1:2,accountId:id==='A'?11:12},personality:{sociability:60,activities:['quest']},relationships:[],memories:[],world:{at:now(),team:1,nearby:id==='A'?['B']:['A'],inCombat:false,groupNames:[],groupId:'',guildId:3,guildName:'Guild',guildRank:0,mapId:0,x:1,y:1}};} test('party acceptance respects faction, level, hostility, combat, and observation freshness',()=>{ const a=bot('A'),b=bot('B');assert.equal(partyDecision(a,b).accepted,true); b.world.team=0;assert.equal(partyDecision(a,b).accepted,false);b.world.team=1; @@ -22,7 +22,7 @@ test('relationship evidence is reciprocal, replay safe, bounded, and excludes de }); test('native commands protect human parties, cap party size, verify identities and enforce guild authority',()=>{ const a=bot('A'),b=bot('B'); - const p=compileCommunityAction('party',a,b);assert.match(p,/Human party is protected/);assert.match(p,/GetMembersCount\(\)>=5/);assert.match(p,/GroupCreate/);assert.match(p,/GetGUIDLow\(\)~=2/); + const p=compileCommunityAction('party',a,b);assert.match(p,/AzerothAIQueueParty/);assert.match(p,/guid=2,account=12/);assert.match(p,/__command_id/); const g=compileCommunityAction('recruit',a,b);assert.match(g,/GetGuildRank\(\)~=0/);assert.match(g,/Already belongs to a guild/); assert.match(compileCommunityAction('leave',a),/Party changed/); }); @@ -34,7 +34,7 @@ test('fresh movement snapshots update guild registry and stale snapshots cannot }); test('offline party members are protected when their identity cannot be checked live',()=>{ - assert.match(compileCommunityAction('party',bot('A'),bot('B')),/Party has offline members/); + assert.match(compileCommunityAction('party',bot('A'),bot('B')),/AzerothAIQueueParty/); assert.match(compileCommunityAction('leave',bot('A')),/Party has offline members/); }); test('recent danger encourages shy companions without overriding hostility or human control',()=>{ diff --git a/tests/parties-lua.test.py b/tests/parties-lua.test.py new file mode 100644 index 0000000..1255847 --- /dev/null +++ b/tests/parties-lua.test.py @@ -0,0 +1,66 @@ +from pathlib import Path +from lupa import LuaRuntime +setup=''' +now=1000;os.time=function()return now end;timers={};receipts={};audit={};people={};rows={{1,'A',11,'active'},{2,'B',12,'active'}};roster={{1,'A',11},{2,'B',12}};removed=0;joins=0 +function query(data)local i=1;return {GetUInt32=function(_,n)return data[i][n+1]end,GetString=function(_,n)return data[i][n+1]end,NextRow=function()i=i+1;return i<=#data end}end +function AuthDBQuery()return query(rows)end +function CharDBQuery()return query(roster)end +function AuthDBExecute(s)table.insert(audit,s)end +function GetGUIDLow(g)return g end +function GetPlayersInWorld()return people end +function GetPlayerByName(n)for _,p in pairs(people)do if p.name==n then return p end end end +function CreateLuaEvent(fn,ms)timers[ms]=fn end +function PrintInfo()end;function PrintError(s)error(s)end +function AzerothAIComplete(id,ok,result)receipts[id]={ok,result}end +function AzerothAIEmitSnapshots()end +function make(id,name) + local p={id=id,name=name,dist=20} + function p:GetName()return self.name end;function p:GetGUIDLow()return self.id end;function p:GetAccountId()return 10+self.id end + function p:IsPlayerbot()return not self.human end;function p:IsDead()return self.dead end;function p:IsInCombat()return self.combat end + function p:IsCasting()return false end;function p:IsTaxi()return false end + function p:GetBotAI()return {HasGameClientMaster=function()return self.controlled end}end + function p:GetTeam()return self.team or 0 end;function p:GetLevel()return 5 end;function p:GetMapId()return 0 end;function p:GetPhaseMask()return 1 end + function p:GetDistance()return self.dist end;function p:GetGroup()return self.group end + function p:GroupCreate(b)joins=joins+1;local g=group({self,b},2);self.group=g;b.group=g;return g end + return p +end +function group(members,count) + local g={members=members,count=count} + function g:GetGUID()return 99 end;function g:IsRaidGroup()return false end;function g:GetMembers()return self.members end;function g:GetMembersCount()return self.count end + function g:GetMemberGUID(name)return name=='A' and 1 or 2 end + function g:RemoveMember(id)removed=removed+1;for _,p in pairs(self.members)do p.group=nil end;return true end + function g:AddMember(p)joins=joins+1;p.group=self;table.insert(self.members,p);self.count=self.count+1;return true end + return g +end +A=make(1,'A');B=make(2,'B');people={A,B} +function invite(id,expires)return AzerothAIQueueParty(id or 'x',{name='A',guid=1,account=11},{name='B',guid=2,account=12},expires or now+45)end +function pulse(dt)now=now+dt;timers[2000]()end +function orphan(state) + rows[2][4]=state;people={A};A.group=group({A},2) + timers[10000]() +end +''' +def new(): + l=LuaRuntime();l.execute(setup);l.execute((Path(__file__).resolve().parents[1]/'ale/azerothai_parties.lua').read_text());return l +l=new();l.execute("A.combat=true;assert(invite().azerothaiDeferred);pulse(2);assert(not receipts.x and joins==0);A.combat=false;pulse(2);assert(receipts.x[1] and joins==1)") +l=new();l.execute("A.dist=100;invite();pulse(2);assert(not receipts.x);A.dist=40;pulse(2);assert(receipts.x[1])") +l=new();l.execute("A.dist=100;invite();pulse(2);A.dist=20;pulse(44);assert(not receipts.x[1] and joins==0)") +l=new();l.execute("invite();B.controlled=true;pulse(2);assert(not receipts.x[1] and joins==0)") +l=new();l.execute("invite();B.team=1;pulse(2);assert(not receipts.x[1] and joins==0)") +l=new();l.execute("invite();assert(invite().azerothaiDeferred);pulse(2);assert(joins==1)") +l=new();l.execute("orphan('dead');assert(removed==1)") +l=new();l.execute("orphan('active');assert(removed==0)") +l=new();l.execute("rows[2][2]='Unknown';orphan('dead');assert(removed==0)") +l=new();l.execute("A.controlled=true;orphan('dead');assert(removed==0)") +print('Native invitation retries, expiration, idempotence, faction/human guards and verified-death cleanup passed') +# Exercise the actual TypeScript wrapper template, including deferred receipt handling. +import re +source=(Path(__file__).resolve().parents[1]/'core/ale.ts').read_text() +wrapper=re.search(r'return `(local __command_id=.*?)`;',source,re.S).group(1) +l=new();l.execute(wrapper.replace('${qid}',"'wrapped'").replace('${source}',"return AzerothAIQueueParty(__command_id,{name='A',guid=1,account=11},{name='B',guid=2,account=12},1045)")) +l.execute("assert(not receipts.wrapped);pulse(2);assert(receipts.wrapped[1])") +l=new();l.execute(wrapper.replace('${qid}',"'ordinary'").replace('${source}','return 7'));l.execute("assert(receipts.ordinary[1] and receipts.ordinary[2]=='7')") +print('Real bridge wrapper preserves ordinary receipts and waits for deferred native completion') + +l=new();l.execute("A.group=group({A},2);invite();pulse(2);assert(not receipts.x and joins==0);pulse(44);assert(not receipts.x[1] and joins==0)") +print('Invitations preserve incomplete living parties while waiting, then expire')