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{ return value&&typeof value==='object'?value as Record:{}; } function latestObservationFor(bot:Bot,events:Awaited>){ 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>[number],events:Awaited>):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(); let s=await store.load(); let 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}=>!!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); 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); const source=compileLivePlayerbotIntent(bot.name,decision); const queued=await queueAleCommand(db,source); 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(), 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; }