diff --git a/ale/azerothai_bridge.lua b/ale/azerothai_bridge.lua index ac1e06f..08954ec 100644 --- a/ale/azerothai_bridge.lua +++ b/ale/azerothai_bridge.lua @@ -145,6 +145,9 @@ local function world_payload(player) local p=player_payload(player) local ai=player:IsPlayerbot() and player:GetBotAI() or nil 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() p.groupId=g and tostring(g:GetGUID()) or "" local members={} diff --git a/ale/azerothai_survival.lua b/ale/azerothai_survival.lua new file mode 100644 index 0000000..b78af83 --- /dev/null +++ b/ale/azerothai_survival.lua @@ -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') diff --git a/core/autonomy.ts b/core/autonomy.ts index 1f5350b..98b2043 100644 --- a/core/autonomy.ts +++ b/core/autonomy.ts @@ -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}:IsPlayerbot() then error("Managed character is not controlled by PlayerbotAI") end`, `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 clearStay=()=>body.push(strategy(ai,'-stay')); diff --git a/core/community.ts b/core/community.ts index 8794d73..196b2bd 100644 --- a/core/community.ts +++ b/core/community.ts @@ -24,7 +24,7 @@ export function observePresence(s:State,b:Bot,p:Record){ if(typeof p.team!=='number'||typeof p.x!=='number'||typeof p.y!=='number')return; const old=b.world; 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); 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){ @@ -46,20 +46,27 @@ export function applyCooperation(s:State,p:Record){ } } 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){ 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'); - 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 danger=recentDanger(a)||recentDanger(b); + 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){ - 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'){ - 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{ 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`); - 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`); + 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('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(); const gate=await db.prepare("UPDATE community_clock SET at=? WHERE id='tick' AND atfresh(b)); - const recent=await db.prepare('SELECT a,b FROM community_actions WHERE created_at>?').bind(new Date(now-10*60_000).toISOString()).all(); - const busy=new Set(recent.results.flatMap(r=>[r.a,r.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,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])); 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. @@ -98,14 +105,14 @@ export async function communityTick(store:StateStore,db:D1Database){ if(c.partiesEnabled&&far){ 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}>(); - 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(); - 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(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)){ 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; } } diff --git a/core/realm-insights.ts b/core/realm-insights.ts index 088c2b7..0478d3f 100644 --- a/core/realm-insights.ts +++ b/core/realm-insights.ts @@ -36,6 +36,7 @@ export function activityReason(b:Bot,now:number){ const w=b.world,action=b.lastDecision?.action; 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.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.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.'}; diff --git a/core/types.ts b/core/types.ts index 238e514..ef438bc 100644 --- a/core/types.ts +++ b/core/types.ts @@ -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 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 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 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}; diff --git a/docs/survival-cooperation.md b/docs/survival-cooperation.md new file mode 100644 index 0000000..8b81314 --- /dev/null +++ b/docs/survival-cooperation.md @@ -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. diff --git a/scripts/recovery-intents.py b/scripts/recovery-intents.py index 321a4cf..7740805 100644 --- a/scripts/recovery-intents.py +++ b/scripts/recovery-intents.py @@ -16,7 +16,8 @@ def publish_recovery_intents(state, cfg): 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 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']}) with urllib.request.urlopen(req,timeout=10) as r: if r.status not in (200,202):raise RuntimeError('Recovery publication failed') diff --git a/tests/community.test.cjs b/tests/community.test.cjs index d9eec26..234617a 100644 --- a/tests/community.test.cjs +++ b/tests/community.test.cjs @@ -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('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)); +}); diff --git a/tests/survival-lua.test.py b/tests/survival-lua.test.py new file mode 100644 index 0000000..4795eea --- /dev/null +++ b/tests/survival-lua.test.py @@ -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')