Files
AzerothAI/core/autonomy.ts
T
2026-09-25 22:57:11 +02:00

223 lines
12 KiB
TypeScript

import {classicMode,classicCommand} from './classic';
import {startIntentCheck} from './intent-evidence';
import {intentCompanions,partyIntentBusy,recordPartyIntent,compileCommunityAction} from './community';
import {DeterministicProvider,providerForBot} from './llm';
import {luaString} from './ale';
import {queueAleCommand,recentAleEvents} from './ale-store';
import type {Activity,Bot,Decision,Observation,RealmEvent,RuntimeConfig,State,StateStore} from './types';
const OBSERVATION_MAX_AGE_MS=90_000;
function realmEvent(s:State,kind:RealmEvent['kind'],message:string,botId?:string){
s.events.push({id:crypto.randomUUID(),seq:++s.sequence,at:new Date().toISOString(),kind,message,botId,mode:'azerothcore'});
s.events=s.events.slice(-500);
}
function payloadObject(value:unknown):Record<string,unknown>{
return value&&typeof value==='object'?value as Record<string,unknown>:{};
}
function latestObservationFor(bot:Bot,events:Awaited<ReturnType<typeof recentAleEvents>>){
const lower=bot.name.toLowerCase();
const guid=bot.autonomy?.characterGuid;
return events.find(e=>{
const p=payloadObject(e.payload);
const sameGuid=typeof guid==='number'&&typeof p.characterGuid==='number'&&p.characterGuid===guid;
const sameName=typeof p.name==='string'&&p.name.toLowerCase()===lower;
return p.isBot===true&&(sameGuid||sameName)&&
['player.snapshot','player.login','player.zone','player.map','player.level','player.quest.accept','player.achievement','player.combat.enter','player.combat.leave'].includes(e.kind);
});
}
export function observationFrom(bot:Bot,event:Awaited<ReturnType<typeof recentAleEvents>>[number],events:Awaited<ReturnType<typeof recentAleEvents>>,hardcore=true):Observation{
const p=payloadObject(event.payload);
const zone=typeof p.zoneName==='string'&&p.zoneName.trim()?p.zoneName.trim().slice(0,120):bot.zone;
const level=typeof p.level==='number'&&Number.isInteger(p.level)&&p.level>0&&p.level<=100?p.level:bot.level;
const recent=events.filter(e=>{
const ep=payloadObject(e.payload);
return typeof ep.name==='string'&&ep.name.toLowerCase()===bot.name.toLowerCase()&&Date.now()-Date.parse(e.at)<5*60_000;
}).slice(0,8).map(e=>e.kind);
return {
zone,
level,
alive:bot.status!=='dead',
availableActions:availableIntentActions(bot),
summary:`Realm death rules: ${hardcore?'permanent death':'normal resurrection; recover and resume progression after death'}. Authoritative live Playerbot observation in ${zone} at level ${level}. Recent server events: ${recent.join(', ')||'snapshot'}. ${decisionContext(bot)}`
};
}
export function availableIntentActions(bot:Bot){
const actions=[...bot.personality.activities],w=bot.world;
if(w&&Date.now()/1000-w.at>=0&&Date.now()/1000-w.at<90&&(w.healthPct??0)>=95&&(w.manaPct===undefined||w.manaPct>=65)&&actions.some(a=>a!=='rest'))return actions.filter(a=>a!=='rest');
return actions;
}
export function decisionContext(bot:Bot){
const w=bot.world;if(!w||Date.now()/1000-w.at>90)return 'Live condition details unavailable; do not invent them.';
const ready=w.readinessAt&&Date.now()/1000-w.readinessAt<180;
const last=bot.autonomy?.intentChecks?.filter(c=>c.status!=='pending').slice(-2).map(c=>`${c.action}: ${c.status}; ${c.detail}`).join(' | ');
return JSON.stringify({health:w.healthPct,mana:w.manaPct,dead:w.isDead||w.isGhost,inCombat:w.inCombat,nearbyThreats:w.threatBrief??'unknown',activeQuestLog:w.questBrief??'unknown',supplies:ready?{food:w.foodCount,drinks:w.drinkCount,durability:w.minDurabilityPct,brokenItems:w.brokenItems}:null,party:w.groupNames,nearby:w.nearby,recentOutcomes:last||'No outcome verified yet',capabilities:'quest/explore request native progression; trade requests native supplies/repair; socialize can invite an eligible nearby companion. Never claim an action completed until observed.'});
}
function strategy(ai:string,change:string){
return `${ai}:ChangeStrategy(${luaString(change)},1)`;
}
export function compileLivePlayerbotIntent(botName:string,decision:Decision){
const p='p',ai='ai';
const prelude=[
`local ${p}=GetPlayerByName(${luaString(botName)})`,
`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 ${p}:IsDead() then return "Native resurrection takes priority" end`,
`if ${ai}:HasGameClientMaster() then return "Human control preserved" end`,
`if ${p}:HasUnitState(0x00000100) then return "Native taxi flight takes priority" end`,
`if AzerothAISurvivalOverride and AzerothAISurvivalOverride(${p}) then return "Native survival action takes priority" end`,
`if AzerothAITrainingOverride and AzerothAITrainingOverride(${p}) then return "Class trainer visit takes priority" end`
];
const body:string[]=[];
const clearStay=()=>body.push(strategy(ai,'-stay'));
switch(decision.action){
case 'explore':
clearStay();
body.push(strategy(ai,'+rpg'));
body.push(`${ai}:DoNextAction(false)`);
break;
case 'quest':
clearStay();
body.push(strategy(ai,'+rpg'));
body.push(strategy(ai,'+grind'));
body.push(`${ai}:DoNextAction(false)`);
break;
case 'socialize':
clearStay();
body.push(strategy(ai,'+emote'));
body.push(`${ai}:DoNextAction(false)`);
break;
case 'trade':
clearStay();
body.push(strategy(ai,'+rpg'));
body.push(`${ai}:DoSpecificAction("rpg sell",true,"");${ai}:DoSpecificAction("rpg buy",true,"");${ai}:DoSpecificAction("repair",true,"")`);
body.push(`${ai}:DoNextAction(false)`);
break;
case 'rest':
clearStay();
body.push(`if ${p}:GetHealthPct()<95 or (${p}:GetPowerType()==0 and ${p}:GetPowerPct(0)<65) then ${ai}:DoSpecificAction("eat",true,"");${ai}:DoSpecificAction("drink",true,"") else ${ai}:DoNextAction(false) end`);
break;
}
body.push(`return ${luaString(`intent ${decision.action} accepted for ${botName}`)}`);
return [...prelude,...body].join('; ');
}
function nextIntervalMs(bot:Bot){
const base=bot.tier==='hero'?75_000:bot.simulationBand==='foreground'?120_000:300_000;
const jitter=[...bot.id].reduce((n,c)=>n+c.charCodeAt(0),0)%30_000;
return base+jitter;
}
export async function runLiveAutonomyTick(
store:StateStore,
db:D1Database,
config:RuntimeConfig,
options:{forceBotId?:string;now?:number}={}
){
const now=options.now??Date.now();
let s=await store.load();
let revision=s.revision;
const events=await recentAleEvents(db,400);
const candidates=s.bots
.filter(b=>b.status==='online'&&b.autonomy?.managed===true&&!b.world?.isDead&&!b.world?.isGhost&&!b.world?.humanControlled&&!b.world?.taxi)
.map(bot=>({bot,obs:latestObservationFor(bot,events)}))
.filter((x):x is {bot:Bot;obs:NonNullable<typeof x.obs>}=>!!x.obs)
.filter(x=>now-Date.parse(x.obs.at)<=OBSERVATION_MAX_AGE_MS)
.filter(x=>!options.forceBotId||x.bot.id===options.forceBotId)
.filter(x=>options.forceBotId||!x.bot.autonomy?.nextIntentAt||Date.parse(x.bot.autonomy.nextIntentAt)<=now)
.sort((a,b)=>{
if(a.bot.tier!==b.bot.tier)return a.bot.tier==='hero'?-1:1;
return Date.parse(a.bot.autonomy?.nextIntentAt??'1970-01-01')-Date.parse(b.bot.autonomy?.nextIntentAt??'1970-01-01');
});
const pick=candidates[0];
if(!pick){
const managed=s.bots.some(b=>b.status!=='dead'&&b.status!=='retired'&&b.autonomy?.managed===true);
if(managed&&!options.forceBotId){
const names=s.bots
.filter(b=>b.status!=='dead'&&b.status!=='retired'&&b.autonomy?.managed===true)
.map(b=>b.name);
const source=[
'local n=0',
...names.map(name=>`if AzerothAIEmitPlayerSnapshot then n=n+AzerothAIEmitPlayerSnapshot(${luaString(name)}) end`),
'return "managed snapshot refresh emitted "..tostring(n)'
].join('; ');
const queued=await queueAleCommand(db,source);
realmEvent(s,'system','Autonomy requested fresh authoritative snapshots for managed Playerbots from ALE.');
await store.save(s,revision);
return {ok:true,ran:false,reason:'No fresh managed Playerbot observation; requested an authoritative snapshot refresh',refreshCommandId:queued.id};
}
return {ok:true,ran:false,reason:options.forceBotId?'No recent authoritative observation for that managed Playerbot':'No managed Playerbot is due with a recent authoritative observation'};
}
let bot=pick.bot;
const observation=observationFrom(bot,pick.obs,events,s.settings.hardcore.enabled);
const guild=s.community?.guilds.find(g=>g.id===bot.world?.guildId);
if(guild)observation.summary+=` Your guild ${guild.name} works toward ${guild.goal}; confirmed progress ${guild.progress}/${guild.target}. Favor helpful actions toward that goal when safe.`;
const companions=intentCompanions(s,bot).slice(0,3);
observation.summary+=' Eligible nearby party candidates: '+(companions.map(p=>`${p.name} (level ${p.level}, ${p.world?.combatRole??p.role})`).join(', ')||'none; do not invent a companion or promise a party')+'.';
const brain=providerForBot(bot,s.settings,config);
let decision:Decision;
let usedFallback=false;
try{
decision=await brain.provider.decide(bot,observation);
}catch(error){
if(brain.route.provider==='deterministic')throw error;
decision=await new DeterministicProvider().decide(bot,observation);
usedFallback=true;
}
// Model inference can take seconds while authoritative events update state.
// Commit the decision against current state, not the pre-inference revision.
const botId=bot.id;
s=await store.load();
revision=s.revision;
const currentBot=s.bots.find(b=>b.id===botId);
if(!currentBot||currentBot.status==='dead'||currentBot.status==='retired'||!currentBot.autonomy?.managed){
return {ok:true,ran:false,reason:'Resident is no longer eligible for an autonomous intention'};
}
bot=currentBot;
if(usedFallback)realmEvent(s,'system',`${bot.name} LLM route unavailable; used deterministic fallback for this heartbeat.`,bot.id);
// Recheck eligibility after inference; native guards recheck once more at execution.
let peer=decision.action==='socialize'?intentCompanions(s,bot)[0]:undefined;
if(peer&&await partyIntentBusy(db,bot,peer))peer=undefined;
const source=classicMode(config)?(peer?classicCommand('party',bot,'',peer):classicCommand('intent',bot,decision.action)):(peer?compileCommunityAction('party',bot,peer):compileLivePlayerbotIntent(bot.name,decision));
const queued=await queueAleCommand(db,source);
if(peer)await recordPartyIntent(db,s,queued.id,bot,peer);
const check=startIntentCheck(bot,decision,queued.id,Date.now()/1000,peer?.name);
bot.lastDecision=decision;
bot.decisions++;
bot.goal=decision.goal;
const newerObservation=Date.parse(bot.autonomy?.observedAt??'')>Date.parse(pick.obs.at);
if(!newerObservation){bot.level=observation.level;bot.zone=observation.zone;}
bot.autonomy={
...(bot.autonomy??{}),
managed:true,
observedAt:newerObservation?bot.autonomy?.observedAt:pick.obs.at,
lastIntentAt:new Date(now).toISOString(),
nextIntentAt:new Date(now+nextIntervalMs(bot)).toISOString(),
intentChecks:[...(bot.autonomy?.intentChecks??[]),...(check?[check]:[])].slice(-12),
lastAleCommandId:queued.id
};
realmEvent(s,'system',`${bot.name} brain route: ${brain.route.provider} · ${brain.route.model} · autonomous live heartbeat.`,bot.id);
realmEvent(s,'decision',`${bot.name} chose to ${decision.action}; the intention was queued to the live PlayerbotAI. Outcome remains authoritative to AzerothCore.`,bot.id);
await store.save(s,revision);
return {ok:true,ran:true,botId:bot.id,name:bot.name,decision,commandId:queued.id,nextIntentAt:bot.autonomy.nextIntentAt};
}
export async function hasRecentLiveObservation(db:D1Database,bot:Bot,now=Date.now()){
const events=await recentAleEvents(db,250);
const obs=latestObservationFor(bot,events);
return !!obs&&now-Date.parse(obs.at)<=OBSERVATION_MAX_AGE_MS;
}