Core tests / core (push) Successful in 9m57s
Full verification / application (push) Failing after 5m16s
Full verification / local-brain (push) Failing after 6m15s
Realm simulation / simulated-live-world (push) Failing after 5m32s
WoW integration lab / real-worldserver (push) Canceled after 17m23s
Source branch: feature/ai-brain-router Source commit: c2a09e0460465201a58aaab56c71424e9a96405e
180 lines
7.4 KiB
TypeScript
180 lines
7.4 KiB
TypeScript
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);
|
|
});
|
|
}
|
|
|
|
function observationFrom(bot:Bot,event:Awaited<ReturnType<typeof recentAleEvents>>[number],events:Awaited<ReturnType<typeof recentAleEvents>>):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:[...bot.personality.activities],
|
|
summary:`Authoritative live Playerbot observation in ${zone} at level ${level}. Recent server events: ${recent.join(', ')||'snapshot'}.`
|
|
};
|
|
}
|
|
|
|
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`
|
|
];
|
|
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,"")`);
|
|
body.push(`${ai}:DoNextAction(false)`);
|
|
break;
|
|
case 'rest':
|
|
body.push(strategy(ai,'+stay'));
|
|
body.push(`${ai}:DoSpecificAction("sit",true,"")`);
|
|
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();
|
|
const s=await store.load();
|
|
const revision=s.revision;
|
|
const events=await recentAleEvents(db,400);
|
|
|
|
const candidates=s.bots
|
|
.filter(b=>b.status!=='dead'&&b.status!=='retired'&&b.autonomy?.managed===true)
|
|
.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'};
|
|
}
|
|
|
|
const bot=pick.bot;
|
|
const observation=observationFrom(bot,pick.obs,events);
|
|
const brain=providerForBot(bot,s.settings,config);
|
|
let decision:Decision;
|
|
try{
|
|
decision=await brain.provider.decide(bot,observation);
|
|
}catch(error){
|
|
if(brain.route.provider==='deterministic')throw error;
|
|
decision=await new DeterministicProvider().decide(bot,observation);
|
|
realmEvent(s,'system',`${bot.name} LLM route unavailable; used deterministic fallback for this heartbeat.`,bot.id);
|
|
}
|
|
const source=compileLivePlayerbotIntent(bot.name,decision);
|
|
const queued=await queueAleCommand(db,source);
|
|
|
|
bot.lastDecision=decision;
|
|
bot.decisions++;
|
|
bot.goal=decision.goal;
|
|
bot.level=observation.level;
|
|
bot.zone=observation.zone;
|
|
bot.autonomy={
|
|
...(bot.autonomy??{}),
|
|
managed:true,
|
|
observedAt:pick.obs.at,
|
|
lastIntentAt:new Date(now).toISOString(),
|
|
nextIntentAt:new Date(now+nextIntervalMs(bot)).toISOString(),
|
|
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;
|
|
}
|