Add danger-aware companion selection and native survival assistance
Core tests / core (push) Successful in 39s

This commit is contained in:
Codex
2026-09-22 21:41:51 -04:00
parent 290926f33f
commit a533c7b28a
10 changed files with 167 additions and 17 deletions
+3
View File
@@ -145,6 +145,9 @@ local function world_payload(player)
local p=player_payload(player) local p=player_payload(player)
local ai=player:IsPlayerbot() and player:GetBotAI() or nil local ai=player:IsPlayerbot() and player:GetBotAI() or nil
p.humanControlled=ai and ai:HasGameClientMaster() or false p.humanControlled=ai and ai:HasGameClientMaster() or false
p.quests={};if ai then for slot=0,24 do local q=player:GetQuestSlotQuestId(slot);if q and q>0 then p.quests[#p.quests+1]=q end end end
if ai then p.combatRole=player:IsHealer() and 'healer' or player:IsTank() and 'tank' or 'damage' end
if AzerothAISurvivalState then p.dangerAt,p.survivalAction,p.survivalAt=AzerothAISurvivalState(player) end
local g=player:GetGroup() local g=player:GetGroup()
p.groupId=g and tostring(g:GetGUID()) or "" p.groupId=g and tostring(g:GetGUID()) or ""
local members={} local members={}
+75
View File
@@ -0,0 +1,75 @@
-- Survival assistance for registered living bots only. No teleport, heal, revive or stat changes.
local registry,observed,ownedMasters={}, {}, {}
local registryAt=0
local enabled,enabledAt=true,0
function AzerothAISetSurvivalEnabled(value) enabled=value==true;enabledAt=os.time() end
local function refresh(now)
if now-registryAt<60 then return end
local q=AuthDBQuery("SELECT guid,name,account_id FROM azerothai_lifecycle WHERE state='active'")
if not q then registry={};return end
local rows={};repeat rows[q:GetUInt32(0)]={name=q:GetString(1),account=q:GetUInt32(2)} until not q:NextRow()
registry=rows;registryAt=now
end
local function managed(p)
if not p or not p:IsPlayerbot() or p:IsDead() then return false end
local r=registry[p:GetGUIDLow()]
return r and r.name==p:GetName() and r.account==p:GetAccountId()
end
local function safeGroup(p)
local g=p:GetGroup();if not g or g:IsRaidGroup() or #g:GetMembers()~=g:GetMembersCount() then return nil end
for _,m in pairs(g:GetMembers()) do if not managed(m) or not m:GetBotAI() or m:GetBotAI():HasGameClientMaster() then return nil end end
return g
end
function AzerothAISurvivalState(p)
local r=observed[p:GetGUIDLow()]
if not r then return 0,'',0 end
return r.dangerAt or 0,r.action or '',r.actionAt or 0
end
-- Prevent slower model intentions from undoing an immediate safety action.
function AzerothAISurvivalOverride(p)
local r=observed[p:GetGUIDLow()]
return r and os.time()-(r.actionAt or 0)<20 and (r.action=='retreat requested' or r.action=='recovering health' or r.action=='regroup requested') or false
end
local function sample()
local now=os.time();refresh(now)
for _,p in pairs(GetPlayersInWorld() or {}) do
if managed(p) then
local id=p:GetGUIDLow();local ai=p:GetBotAI();local r=observed[id] or {};observed[id]=r
local hp=p:GetHealthPct()
if p:IsInCombat() and (hp<40 or r.hp and r.hp-hp>=25) then r.dangerAt=now end
r.hp=hp;r.seen=now
local master=ai and ai:GetMaster()
if ownedMasters[id] and (not p:GetGroup() or not master or not master:GetGroup() or master:GetGroup()~=p:GetGroup()) then
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:IsCasting() and not p:IsRooted() and not ai:IsMovementImpaired() then
local g=safeGroup(p)
-- 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=0
for _,m in pairs(peers) do if m:GetMapId()==p:GetMapId() and p:GetDistance(m)<40 and m:GetHealthPct()<40 then weak=weak+1 end end
local enemy=p:GetVictim()
local overmatched=enemy and enemy:GetLevel()>=p:GetLevel()+4 and hp<65
if p:IsInCombat() and (hp<30 or overmatched or #peers>1 and weak>=math.max(2,math.ceil(#peers/2))) and now-(r.retreatAt or 0)>=20 then
r.dangerAt=now;r.retreatAt=now;r.action='retreat requested';r.actionAt=now
r.accepted=ai:DoSpecificAction('flee',true)
elseif not p:IsInCombat() and hp<65 and now-(r.dangerAt or 0)<600 and now-(r.restAt or 0)>=20 then
r.restAt=now;r.action='recovering health';r.actionAt=now
ai:DoSpecificAction('eat',true);ai:DoSpecificAction('drink',true)
elseif enabled and now-enabledAt<180 and g and not p:IsInCombat() then
local leader=ai:GetGroupLeader()
if leader and leader~=p and managed(leader) and not leader:IsInCombat() and not leader:IsTaxi() and leader:GetMapId()==p:GetMapId() and leader:GetPhaseMask()==p:GetPhaseMask() and p:GetDistance(leader)>18 and p:GetDistance(leader)<90 and hp>=65 and now-(r.followAt or 0)>=20 then
ai:SetMaster(leader);ownedMasters[id]=leader:GetGUIDLow()
ai:ChangeStrategy('-stay',1)
r.followAt=now;r.action='regroup requested';r.actionAt=now;r.accepted=ai:DoSpecificAction('follow',true)
end
end
end
end
end
end
for id,r in pairs(observed) do if now-(r.seen or 0)>600 then observed[id]=nil;ownedMasters[id]=nil end end
end
CreateLuaEvent(function()local ok,err=pcall(sample);if not ok then PrintError('[AzerothAI survival] '..tostring(err)) end end,10000,0)
PrintInfo('[AzerothAI] Survival assistance enabled: danger memory, native retreat and nearby regrouping')
+3 -1
View File
@@ -54,7 +54,9 @@ export function compileLivePlayerbotIntent(botName:string,decision:Decision){
`if not ${p} then error("Managed Playerbot is not online") end`, `if not ${p} then error("Managed Playerbot is not online") end`,
`if not ${p}:IsPlayerbot() then error("Managed character is not controlled by PlayerbotAI") end`, `if not ${p}:IsPlayerbot() then error("Managed character is not controlled by PlayerbotAI") end`,
`local ${ai}=${p}:GetBotAI()`, `local ${ai}=${p}:GetBotAI()`,
`if not ${ai} then error("PlayerbotAI unavailable") end` `if not ${ai} then error("PlayerbotAI unavailable") end`,
`if ${ai}:HasGameClientMaster() then return "Human control preserved" end`,
`if AzerothAISurvivalOverride and AzerothAISurvivalOverride(${p}) then return "Native survival action takes priority" end`
]; ];
const body:string[]=[]; const body:string[]=[];
const clearStay=()=>body.push(strategy(ai,'-stay')); const clearStay=()=>body.push(strategy(ai,'-stay'));
+21 -14
View File
@@ -24,7 +24,7 @@ export function observePresence(s:State,b:Bot,p:Record<string,unknown>){
if(typeof p.team!=='number'||typeof p.x!=='number'||typeof p.y!=='number')return; if(typeof p.team!=='number'||typeof p.x!=='number'||typeof p.y!=='number')return;
const old=b.world; const old=b.world;
const stationary=old&&old.mapId===p.mapId&&Math.hypot(old.x-p.x,old.y-p.y)<3; const stationary=old&&old.mapId===p.mapId&&Math.hypot(old.x-p.x,old.y-p.y)<3;
b.world={casting:p.casting===true,taxi:p.taxi===true,humanControlled:p.humanControlled===true,movementType:typeof p.movementType==='number'?p.movementType:undefined,healthPct:typeof p.healthPct==='number'?p.healthPct:undefined,at,team:p.team,guildId:Number(p.guildId)||0,guildName:String(p.guildName||''),guildRank:Number(p.guildRank)||0,groupId:String(p.groupId||''),groupNames:String(p.groupNames||'').split(',').filter(Boolean),nearby:String(p.nearbyBots||'').split(',').filter(Boolean),inCombat:p.inCombat===true,x:p.x,y:p.y,mapId:Number(p.mapId)||0,stationarySince:stationary?(old.stationarySince??old.at):at}; b.world={quests:Array.isArray(p.quests)?p.quests.filter((q):q is number=>typeof q==='number'&&q>0).slice(0,25):undefined,dangerAt:typeof p.dangerAt==='number'?p.dangerAt:old?.dangerAt,survivalAction:typeof p.survivalAction==='string'?p.survivalAction:undefined,survivalAt:typeof p.survivalAt==='number'?p.survivalAt:undefined,combatRole:typeof p.combatRole==='string'?p.combatRole:undefined,casting:p.casting===true,taxi:p.taxi===true,humanControlled:p.humanControlled===true,movementType:typeof p.movementType==='number'?p.movementType:undefined,healthPct:typeof p.healthPct==='number'?p.healthPct:undefined,at,team:p.team,guildId:Number(p.guildId)||0,guildName:String(p.guildName||''),guildRank:Number(p.guildRank)||0,groupId:String(p.groupId||''),groupNames:String(p.groupNames||'').split(',').filter(Boolean),nearby:String(p.nearbyBots||'').split(',').filter(Boolean),inCombat:p.inCombat===true,x:p.x,y:p.y,mapId:Number(p.mapId)||0,stationarySince:stationary?(old.stationarySince??old.at):at};
const c=community(s); const c=community(s);
if(b.world.guildId&&!c.guilds.some(g=>g.id===b.world!.guildId))c.guilds.push({id:b.world.guildId,name:b.world.guildName,goal:['quest','support','explore'][b.world.guildId%3] as 'quest'|'support'|'explore',progress:0,target:20}); if(b.world.guildId&&!c.guilds.some(g=>g.id===b.world!.guildId))c.guilds.push({id:b.world.guildId,name:b.world.guildName,goal:['quest','support','explore'][b.world.guildId%3] as 'quest'|'support'|'explore',progress:0,target:20});
if(old&&old.mapId!==b.world.mapId&&b.world.guildId){ if(old&&old.mapId!==b.world.mapId&&b.world.guildId){
@@ -46,20 +46,27 @@ export function applyCooperation(s:State,p:Record<string,unknown>){
} }
} }
const affinity=(a:Bot,b:Bot)=>a.relationships.find(r=>r.targetId===b.id)?.affinity??0; const affinity=(a:Bot,b:Bot)=>a.relationships.find(r=>r.targetId===b.id)?.affinity??0;
const fresh=(b:Bot)=>b.status==='online'&&b.autonomy?.managed&&b.world&&Date.now()/1000-b.world.at<90&&!b.world.inCombat; export const recentDanger=(b:Bot)=>!!b.world?.dangerAt&&Date.now()/1000-b.world.dangerAt<600;
export function companionScore(a:Bot,b:Bot){
const roleBonus=b.world?.combatRole==='healer'&&a.world?.combatRole!=='healer'?40:b.world?.combatRole==='tank'&&a.world?.combatRole!=='tank'?20:0;
const shared=(a.world?.quests??[]).filter(q=>b.world?.quests?.includes(q)).length;
return Math.min(40,shared*15)+(recentDanger(b)?30:0)+roleBonus+Math.min(20,Math.max(0,affinity(a,b)))+Math.max(0,10-Math.abs(a.level-b.level));
}
const fresh=(b:Bot)=>b.status==='online'&&b.autonomy?.managed&&b.world&&Date.now()/1000-b.world.at<90&&!b.world.inCombat&&!b.world.humanControlled&&!b.world.taxi&&!b.world.casting;
export function partyDecision(a:Bot,b:Bot){ export function partyDecision(a:Bot,b:Bot){
if(!fresh(a)||!fresh(b)||a.id===b.id||a.world!.team!==b.world!.team||Math.abs(a.level-b.level)>10||!a.world!.nearby.includes(b.name)||!b.world!.nearby.includes(a.name))return {accepted:false,reason:'No compatible nearby pair'}; if(!fresh(a)||!fresh(b)||a.id===b.id||a.world!.team!==b.world!.team||Math.abs(a.level-b.level)>10||!a.world!.nearby.includes(b.name)||!b.world!.nearby.includes(a.name))return {accepted:false,reason:'No compatible nearby pair'};
const willing=(x:Bot,y:Bot)=>affinity(x,y)>=0&&(x.personality.sociability>=35||affinity(x,y)>=5)&&x.personality.activities.some(v=>v==='quest'||v==='explore'); const danger=recentDanger(a)||recentDanger(b);
return {accepted:willing(a,b)&&willing(b,a),reason:willing(a,b)&&willing(b,a)?'Both accepted: compatible levels, shared adventure interests, and no hostile bond.':'One character prefers to adventure alone or does not trust the other.'}; const willing=(x:Bot,y:Bot)=>affinity(x,y)>=0&&(danger||x.personality.sociability>=35||affinity(x,y)>=5)&&x.personality.activities.some(v=>v==='quest'||v==='explore');
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){ export function compileCommunityAction(kind:'party'|'leave'|'recruit',a:Bot,b?:Bot){
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() then error('Resident unavailable') end`]; 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'){ 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() then error('Human party is protected') end end`,`a:RemoveFromGroup()`,`if a:GetGroup() then error('Departure was not applied') end`); 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{ }else{
if(!b)throw new Error('Peer required'); 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 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`); 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() 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`); 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`); 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('AzerothAIEmitSnapshots()','return "Confirmed '+kind+'"');return lines.join('; '); lines.push('AzerothAIEmitSnapshots()','return "Confirmed '+kind+'"');return lines.join('; ');
@@ -88,9 +95,9 @@ export async function communityTick(store:StateStore,db:D1Database){
await db.prepare("INSERT OR IGNORE INTO community_clock VALUES ('tick',0)").run(); await db.prepare("INSERT OR IGNORE INTO community_clock VALUES ('tick',0)").run();
const gate=await db.prepare("UPDATE community_clock SET at=? WHERE id='tick' AND at<?").bind(now,now-30_000).run();if(gate.meta.changes!==1)return; const gate=await db.prepare("UPDATE community_clock SET at=? WHERE id='tick' AND at<?").bind(now,now-30_000).run();if(gate.meta.changes!==1)return;
await db.prepare("DELETE FROM community_actions WHERE status!='pending' AND created_at<?").bind(new Date(now-7*86400_000).toISOString()).run(); await db.prepare("DELETE FROM community_actions WHERE status!='pending' AND created_at<?").bind(new Date(now-7*86400_000).toISOString()).run();
const s=await store.load(),c=community(s),bots=s.bots.filter(b=>fresh(b)); const s=await store.load(),c=community(s),bots=s.bots.filter(b=>fresh(b)).sort((a,b)=>Number(recentDanger(b))-Number(recentDanger(a)));
const recent=await db.prepare('SELECT a,b FROM community_actions WHERE created_at>?').bind(new Date(now-10*60_000).toISOString()).all<any>(); 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<any>();
const busy=new Set(recent.results.flatMap(r=>[r.a,r.b])); 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]));
for(const a of bots){ for(const a of bots){
if(busy.has(a.id))continue; if(busy.has(a.id))continue;
// Leave only a bot-only party whose peers have moved away, never a human party. // Leave only a bot-only party whose peers have moved away, never a human party.
@@ -98,14 +105,14 @@ export async function communityTick(store:StateStore,db:D1Database){
if(c.partiesEnabled&&far){ if(c.partiesEnabled&&far){
await db.prepare('INSERT OR IGNORE INTO community_clock VALUES (?,?)').bind('apart:'+a.id,now).run(); await db.prepare('INSERT OR IGNORE INTO community_clock VALUES (?,?)').bind('apart:'+a.id,now).run();
const apart=await db.prepare('SELECT at FROM community_clock WHERE id=?').bind('apart:'+a.id).first<{at:number}>(); const apart=await db.prepare('SELECT at FROM community_clock WHERE id=?').bind('apart:'+a.id).first<{at:number}>();
if(apart&&now-apart.at>10*60_000){await propose('leave',a,undefined,'Left the party after ten minutes apart.');return;} if(apart&&now-apart.at>20*60_000){await propose('leave',a,undefined,'Left the party after twenty minutes apart despite nearby regroup attempts.');return;}
}else await db.prepare('DELETE FROM community_clock WHERE id=?').bind('apart:'+a.id).run(); }else await db.prepare('DELETE FROM community_clock WHERE id=?').bind('apart:'+a.id).run();
for(const b of bots){ for(const b of [...bots].sort((x,y)=>companionScore(a,y)-companionScore(a,x))){
if(busy.has(b.id)||a.id===b.id||!a.world!.nearby.includes(b.name))continue; if(busy.has(b.id)||a.id===b.id||!a.world!.nearby.includes(b.name))continue;
if(c.guildsEnabled&&a.world!.guildId&&a.world!.guildRank===0&&!b.world!.guildId&&affinity(a,b)>=3&&affinity(b,a)>=3&&partyDecision(a,b).accepted){await propose('recruit',a,b,`Accepted an invitation to ${a.world!.guildName} after building mutual trust.`);return;} if(c.guildsEnabled&&a.world!.guildId&&a.world!.guildRank===0&&!b.world!.guildId&&affinity(a,b)>=3&&affinity(b,a)>=3&&partyDecision(a,b).accepted){await propose('recruit',a,b,`Accepted an invitation to ${a.world!.guildName} after building mutual trust.`);return;}
if(c.partiesEnabled&&!b.world!.groupId&&(!a.world!.groupId||a.world!.groupNames.length<5)){ if(c.partiesEnabled&&!b.world!.groupId&&(!a.world!.groupId||a.world!.groupNames.length<5)){
const decision=partyDecision(a,b); const decision=partyDecision(a,b);
if(!decision.accepted){if(decision.reason==='No compatible nearby pair')continue;await db.prepare('INSERT INTO community_actions VALUES (?,?,?,?,?,?,?,?)').bind(crypto.randomUUID(),new Date().toISOString(),'declined',a.id,b.id,'','declined',decision.reason).run();await changeCommunity(store,s=>socialEntry(s,{id:crypto.randomUUID(),at:new Date().toISOString(),kind:'invitation',names:[a.name,b.name],text:decision.reason,status:'declined'}));return;} if(!decision.accepted)continue;
await propose('party',a,b,decision.reason);return; await propose('party',a,b,decision.reason);return;
} }
} }
+1
View File
@@ -36,6 +36,7 @@ export function activityReason(b:Bot,now:number){
const w=b.world,action=b.lastDecision?.action; const w=b.world,action=b.lastDecision?.action;
if(b.status!=='online')return {label:b.status,detail:'This resident is not currently online.'}; if(b.status!=='online')return {label:b.status,detail:'This resident is not currently online.'};
if(!w||now-w.at>120)return {label:'Telemetry delayed',detail:'Current activity cannot be confirmed from stale observations.'}; if(!w||now-w.at>120)return {label:'Telemetry delayed',detail:'Current activity cannot be confirmed from stale observations.'};
if(w.survivalAt&&now-w.survivalAt<30&&w.survivalAction)return {label:w.survivalAction,detail:'Native survival assistance requested this action. Completion remains determined by game observations.'};
if(w.inCombat)return {label:'In combat',detail:'The game reports active combat. Recovery is skipped.'}; if(w.inCombat)return {label:'In combat',detail:'The game reports active combat. Recovery is skipped.'};
if(w.casting)return {label:'Casting',detail:'The game reports a spell cast. Recovery is skipped.'}; if(w.casting)return {label:'Casting',detail:'The game reports a spell cast. Recovery is skipped.'};
if(w.taxi)return {label:'Traveling by taxi',detail:'The game reports taxi travel. Recovery is skipped.'}; if(w.taxi)return {label:'Traveling by taxi',detail:'The game reports taxi travel. Recovery is skipped.'};
+1 -1
View File
@@ -10,7 +10,7 @@ export type DeathRecord = {id:string; at:string; zone:string; level:number; caus
export type Home = {id:string; name:string; zone:string; type:HousingType; capacity:number; ownerIds:string[]; residentIds:string[]; storageSlots:number; safety:number; description:string}; export type Home = {id:string; name:string; zone:string; type:HousingType; capacity:number; ownerIds:string[]; residentIds:string[]; storageSlots:number; safety:number; description:string};
export type Routine = {wakeHour:number; sleepHour:number; preferredZones:string[]}; export type Routine = {wakeHour:number; sleepHour:number; preferredZones:string[]};
export type AutonomyState = {managed:boolean; characterGuid?:number; accountId?:number; observedAt?:string; lastIntentAt?:string; nextIntentAt?:string; lastAleCommandId?:string}; export type AutonomyState = {managed:boolean; characterGuid?:number; accountId?:number; observedAt?:string; lastIntentAt?:string; nextIntentAt?:string; lastAleCommandId?:string};
export type WorldPresence = {casting?:boolean;taxi?:boolean;humanControlled?:boolean;movementType?:number;healthPct?:number;at:number;team:number;guildId:number;guildName:string;guildRank:number;groupId:string;groupNames:string[];nearby:string[];inCombat:boolean;x:number;y:number;mapId:number;stationarySince?:number}; export type WorldPresence = {quests?:number[];dangerAt?:number;survivalAction?:string;survivalAt?:number;combatRole?:string;casting?:boolean;taxi?:boolean;humanControlled?:boolean;movementType?:number;healthPct?:number;at:number;team:number;guildId:number;guildName:string;guildRank:number;groupId:string;groupNames:string[];nearby:string[];inCombat:boolean;x:number;y:number;mapId:number;stationarySince?:number};
export type SocialEntry = {id:string;at:string;kind:string;names:string[];text:string;affinityDelta?:number;status?:string}; export type SocialEntry = {id:string;at:string;kind:string;names:string[];text:string;affinityDelta?:number;status?:string};
export type GuildGoal = {id:number;name:string;goal:'quest'|'support'|'explore';progress:number;target:number}; export type GuildGoal = {id:number;name:string;goal:'quest'|'support'|'explore';progress:number;target:number};
export type Community = {feed:SocialEntry[];evidence:string[];guilds:GuildGoal[];partiesEnabled:boolean;guildsEnabled:boolean}; export type Community = {feed:SocialEntry[];evidence:string[];guilds:GuildGoal[];partiesEnabled:boolean;guildsEnabled:boolean};
+13
View File
@@ -0,0 +1,13 @@
# Survival and cooperation
Native survival assistance samples registered, living bots every ten seconds. Health below 40% in combat or a drop of at least 25 percentage points records ten minutes of recent danger. Party selection gives those residents priority; recent danger bypasses sociability reluctance, but never hostility, faction/level compatibility, proximity or human-control checks. Candidate ranking favors shared active quest IDs and complementary native healer/tank roles. It does not change class, spec, gear or quest state.
Party actions remain receipt-confirmed, one per community tick. Participants wait two minutes after a completed action or one minute after a rejected command before another invitation; pending commands remain excluded. Separated parties now have twenty minutes before automatic departure, giving native regrouping a chance. Declined candidate pairs no longer prevent the same tick from considering a better match.
The native guard requests the existing `flee` action at most every twenty seconds when health falls below 30%, an enemy is at least four levels higher and health is below 65%, or at least two members and at least half the party are nearby and below 40%. This is a movement request, not guaranteed escape. After danger, injured bots request normal eating/drinking actions; food, mana and other game requirements still apply. No health, level or resurrection changes occur.
Healthy, noncombat followers 18–90 yards from their bot leader on the same map/phase can request normal follow movement. Human-led, mixed, raid and partly offline parties are excluded. The guard does not teleport or force cross-map travel. A master relationship assigned for following is cleared when that party relationship ends. Slower model commands yield to a recent native survival request rather than immediately replacing it. An accepted request does not establish shared quest completion or successful escape; existing gameplay and cooperation counters remain the outcome evidence.
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.
+2 -1
View File
@@ -16,7 +16,8 @@ def publish_recovery_intents(state, cfg):
guid=identity.get('characterGuid');account=identity.get('accountId') guid=identity.get('characterGuid');account=identity.get('accountId')
if type(guid) is not int or type(account) is not int or guid<=0 or account<=0:continue if type(guid) is not int or type(account) is not int or guid<=0 or account<=0:continue
rows.append('{guid=%d,account=%d,name=%s,action=%s}'%(guid,account,literal(bot['name']),literal(action))) rows.append('{guid=%d,account=%d,name=%s,action=%s}'%(guid,account,literal(bot['name']),literal(action)))
source='if not AzerothAISetRecoveryIntents then error("Recovery guard not loaded") end; return AzerothAISetRecoveryIntents({'+','.join(rows)+'})' survival='true' if state.get('community',{}).get('partiesEnabled',True) else 'false'
source='if AzerothAISetSurvivalEnabled then AzerothAISetSurvivalEnabled('+survival+') end; if not AzerothAISetRecoveryIntents then error("Recovery guard not loaded") end; return AzerothAISetRecoveryIntents({'+','.join(rows)+'})'
req=urllib.request.Request('http://127.0.0.1:8787/api/ale/operator',data=json.dumps({'kind':'raw','source':source}).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+cfg['ALE_OPERATOR_TOKEN']}) req=urllib.request.Request('http://127.0.0.1:8787/api/ale/operator',data=json.dumps({'kind':'raw','source':source}).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+cfg['ALE_OPERATOR_TOKEN']})
with urllib.request.urlopen(req,timeout=10) as r: with urllib.request.urlopen(req,timeout=10) as r:
if r.status not in (200,202):raise RuntimeError('Recovery publication failed') if r.status not in (200,202):raise RuntimeError('Recovery publication failed')
+11
View File
@@ -37,3 +37,14 @@ test('offline party members are protected when their identity cannot be checked
assert.match(compileCommunityAction('party',bot('A'),bot('B')),/Party has offline members/); assert.match(compileCommunityAction('party',bot('A'),bot('B')),/Party has offline members/);
assert.match(compileCommunityAction('leave',bot('A')),/Party has offline members/); assert.match(compileCommunityAction('leave',bot('A')),/Party has offline members/);
}); });
test('recent danger encourages shy companions without overriding hostility or human control',()=>{
const {partyDecision}=require('../.test-build/community.js');const a=bot('A'),b=bot('B');a.personality.sociability=0;b.personality.sociability=0;
assert.equal(partyDecision(a,b).accepted,false);a.world.dangerAt=now();assert.equal(partyDecision(a,b).accepted,true);
b.relationships=[{targetId:'A',affinity:-1}];assert.equal(partyDecision(a,b).accepted,false);b.relationships=[];
b.world.humanControlled=true;assert.equal(partyDecision(a,b).accepted,false);b.world.humanControlled=false;
a.world.dangerAt=now()-601;assert.equal(partyDecision(a,b).accepted,false);
});
test('companion ranking prefers complementary native roles and shared active quests',()=>{
const {companionScore}=require('../.test-build/community.js');const a=bot('A'),b=bot('B'),c=bot('C');a.world.combatRole='tank';b.world.combatRole='healer';c.world.combatRole='damage';assert.ok(companionScore(a,b)>companionScore(a,c));
b.world.combatRole='damage';a.world.quests=[1,2];c.world.quests=[1,2];assert.ok(companionScore(a,c)>companionScore(a,b));
});
+37
View File
@@ -0,0 +1,37 @@
from pathlib import Path
from lupa import LuaRuntime
setup='''
now=1000;os.time=function()return now end;calls={};people={}
function AuthDBQuery(sql)local i=1;return {GetUInt32=function(_,n)return n==0 and i or 10+i end,GetString=function()return i==1 and 'A' or 'B'end,NextRow=function()i=i+1;return i<=2 end} end
function GetPlayersInWorld()return people end
function CreateLuaEvent(fn)tick=fn end
function PrintInfo()end
function PrintError(s)error(s)end
function make(id,name)
local p={id=id,name=name,hp=100,distance=25,ai={}}
function p:GetGUIDLow()return self.id end;function p:GetName()return self.name 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:GetBotAI()return self.ai end
function p:GetHealthPct()return self.hp end;function p:IsInCombat()return self.combat end
function p:IsTaxi()return self.taxi end;function p:IsCasting()return self.cast end;function p:IsRooted()return false end
function p:GetGroup()return self.group end;function p:GetVictim()return self.enemy end;function p:GetLevel()return 5 end
function p:GetDistance()return self.distance end;function p:GetMapId()return 0 end;function p:GetPhaseMask()return 1 end
function p.ai:GetMaster()return p.master end;function p.ai:SetMaster(m)p.master=m end
function p.ai:HasGameClientMaster()return p.controlled end;function p.ai:IsMovementImpaired()return false end
function p.ai:GetGroupLeader()return leader end;function p.ai:ChangeStrategy(s,state)end
function p.ai:DoSpecificAction(s)table.insert(calls,s);return true end
return p
end
A=make(1,'A');B=make(2,'B');people={A,B};leader=A
function group() local g={GetMembers=function()return {A,B}end,GetMembersCount=function()return 2 end,IsRaidGroup=function()return false end};A.group=g;B.group=g end
function step(n)now=now+n;tick()end
'''
def new():
l=LuaRuntime();l.execute(setup);l.execute((Path(__file__).resolve().parents[1]/'ale/azerothai_survival.lua').read_text());l.execute('AzerothAISetSurvivalEnabled(true)');return l
l=new();l.execute("A.combat=true;A.hp=20;step(0);assert(calls[1]=='flee');step(10);assert(#calls==1);step(10);assert(#calls==2)")
l=new();l.execute("group();A.human=true;B.combat=true;B.hp=20;step(0);assert(#calls==0)")
l=new();l.execute("group();step(0);assert(calls[1]=='follow' and B.master==A)")
l=new();l.execute("group();B.distance=100;step(0);assert(#calls==0)")
l=new();l.execute("A.combat=true;A.hp=20;A.controlled=true;step(0);assert(#calls==0)")
l=new();l.execute("A.combat=true;A.hp=20;step(0);A.combat=false;step(20);assert(calls[2]=='eat' and calls[3]=='drink')")
l=new();l.execute("group();AzerothAISetSurvivalEnabled(false);step(0);assert(#calls==0)")
print('Native survival: retreat cooldown, recovery, regroup range, human-party protection, and disabled grouping passed')