Files
AzerothAI/core/service.ts

332 lines
22 KiB
TypeScript

import {community,observePresence,applyCooperation,socialEntry} from './community';
import { runGM } from './gm';
import { actionSchema } from './validation';
import { connectors } from './connectors';
import { providerForBot } from './llm';
import { applySimulationPopulationPlan,chooseHomeForNewResident,runPopulationDirector } from './world';
import type { State,StateStore,RuntimeConfig,Bot,RealmEvent,Status,Observation,DeathRecord } from './types';
function event(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:s.settings.connector});
s.events=s.events.slice(-500);
}
function botById(s:State,id:string){const b=s.bots.find(b=>b.id===id);if(!b)throw new Error('Bot not found');return b;}
function observedText(payload:Record<string,unknown>,key:string){
const value=payload[key];
return typeof value==='string'&&value.trim()?value.trim():undefined;
}
function recipeForObservedBot(s:State,name:string,role:string){
if(name.toLowerCase()==='zaku')return s.recipes.find(r=>r.id==='zaku')??s.recipes[0];
if(['Priest','Paladin','Druid','Shaman'].includes(role))return s.recipes.find(r=>r.id==='healer')??s.recipes[0];
if(['Warrior','Death Knight'].includes(role))return s.recipes.find(r=>r.id==='veteran')??s.recipes[0];
if(role==='Rogue')return s.recipes.find(r=>r.id==='trader')??s.recipes[0];
return s.recipes.find(r=>r.id==='explorer')??s.recipes[0];
}
function memorialize(s:State,dead:Bot){
if(!s.settings.hardcore.memorialize)return;
const text=`${dead.name} died permanently at level ${dead.level} in ${dead.death?.zone??dead.zone}. ${dead.death?.cause??'Cause unknown.'}`;
for(const bot of s.bots){
if(bot.id===dead.id||bot.status==='dead'||bot.status==='retired')continue;
const knewThem=bot.relationships.some(r=>r.targetId===dead.id)||dead.relationships.some(r=>r.targetId===bot.id)||bot.tier==='hero';
if(!knewThem)continue;
bot.memories.push({id:crypto.randomUUID(),text,importance:9,source:s.settings.connector==='simulation'?'simulation':'observation',at:new Date().toISOString()});
bot.memories=bot.memories.slice(-100);
}
}
function recordDeath(s:State,b:Bot,death:DeathRecord){
if(b.status==='dead')return;
if(b.plotArmorHpFloor>=1)throw new Error(`${b.name} has plot armor and cannot enter the death state; live combat must clamp HP to ${b.plotArmorHpFloor}.`);
if(!b.mortal)throw new Error(`${b.name} is configured as immortal and cannot enter the hardcore death state`);
b.status='dead';
b.death=death;
memorialize(s,b);
event(s,'death',`${b.name} died at level ${death.level} in ${death.zone}. One life ended permanently. Cause: ${death.cause}${death.killer?` · Killer: ${death.killer}`:''}`,b.id);
}
function observedHardcoreDeath(s:State,b:Bot,observation:Observation){
if(observation.alive!==false)return false;
if(b.plotArmorHpFloor>=1)throw new Error(`Plot armor breach: ${b.name} was reported dead by the game bridge. The worldserver must keep this character at or above ${b.plotArmorHpFloor} HP.`);
if(!s.settings.hardcore.enabled){
event(s,'lifecycle',`${b.name} is currently dead in-game; decision skipped while standard resurrection rules are enabled.`,b.id);
return true;
}
if(s.settings.hardcore.requireVerifiedDeath&&(!observation.death||!observation.death.verified)){
throw new Error('Game bridge reported a death without a verified death event. Hardcore identity was NOT deleted.');
}
const signal=observation.death;
recordDeath(s,b,{
id:signal?.id??crypto.randomUUID(),
at:signal?.at??new Date().toISOString(),
zone:signal?.zone??observation.zone,
level:observation.level,
cause:signal?.cause??'Game server reported character death',
killer:signal?.killer,
source:s.settings.connector,
verified:signal?.verified??false
});
return true;
}
export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,payload:Record<string,unknown>){
let s=await store.load();
const revision=s.revision;
if(kind==='player.snapshot'&&payload.cooperation){applyCooperation(s,payload);await store.save(s,revision);return {matched:true,cooperation:true};}
// Complete world snapshots own presence once available; historical events
// must never bring disconnected residents back online.
if(kind==='player.snapshot' && payload.roster===true){
const at=payload.observedAt;
const guids=payload.characterGuids;
if(typeof at!=='number'||!Number.isFinite(at)||!Array.isArray(guids)||!guids.every(g=>Number.isInteger(g)&&g>0))throw new Error('Invalid authoritative roster');
if(at<=(s.aleRosterAt??0))return {matched:false,stale:true};
const online=new Set(guids);
for(const bot of s.bots){
if(bot.status==='dead'||bot.status==='retired')continue;
const present=bot.autonomy?.managed && online.has(bot.autonomy.characterGuid);
bot.status=present?'online':'offline';
bot.simulationBand=present?'background':'dormant';
}
s.aleRosterAt=at;
await store.save(s,revision);
return {matched:true,roster:true};
}
if(kind==='bridge.ready'){
let changed=false;
for(const bot of s.bots){
if(bot.status==='dead'||bot.status==='retired'||bot.autonomy?.managed)continue;
if(bot.status==='online'||bot.status==='paused'){
bot.status='offline';
bot.simulationBand='dormant';
changed=true;
}
}
if(changed){
event(s,'system','Authoritative AzerothCore bridge came online. Unobserved prototype residents were moved offline until a real Playerbot is observed.');
await store.save(s,revision);
}
return {matched:false,bridgeReady:true};
}
const name=typeof payload.name==='string'?payload.name.trim():'';
if(payload.isBot!==true||!name)return {matched:false};
const characterGuid=typeof payload.characterGuid==='number'&&Number.isInteger(payload.characterGuid)&&payload.characterGuid>0?payload.characterGuid:undefined;
const accountId=typeof payload.accountId==='number'&&Number.isInteger(payload.accountId)&&payload.accountId>0?payload.accountId:undefined;
let b=(characterGuid?s.bots.find(x=>x.autonomy?.characterGuid===characterGuid):undefined)??s.bots.find(x=>x.name.toLowerCase()===name.toLowerCase());
if(!b){
const role=observedText(payload,'className');
const race=observedText(payload,'raceName');
if(!role||!race)return {matched:false,reason:'Observed Playerbot is missing class/race identity metadata'};
const zone=observedText(payload,'zoneName')?.slice(0,120)??'Unknown';
const level=typeof payload.level==='number'&&Number.isInteger(payload.level)&&payload.level>0&&payload.level<=100?payload.level:1;
const recipe=recipeForObservedBot(s,name,role);
const isZaku=name.toLowerCase()==='zaku';
b={
id:isZaku?'hero-zaku':`live-${crypto.randomUUID()}`,
name,role,race,level,zone,status:s.aleRosterAt?'offline':'online',
tier:isZaku?'hero':'resident',mortal:true,plotArmorHpFloor:isZaku?1:0,
simulationBand:'background',
routine:{wakeHour:isZaku?10:7,sleepHour:isZaku?3:23,preferredZones:[zone]},
autonomy:{managed:true,characterGuid,accountId,observedAt:new Date().toISOString()},
recipeId:recipe.id,recipeVersion:recipe.version,personality:structuredClone(recipe),
goal:isZaku
?'Survive, build a life in Azeroth, return home when I need safety, form real relationships, and pursue goals that matter even when no human player is online.'
:'Build a life in Azeroth, pursue personal goals, and respond to the people and events I actually encounter.',
memories:[{id:crypto.randomUUID(),text:`I am in ${zone}. This is where my current life in Azeroth is unfolding.`,importance:6,source:'observation',at:new Date().toISOString()}],
relationships:[],decisions:0
};
const home=chooseHomeForNewResident(s,b);
if(home){b.homeId=home.id;home.residentIds.push(b.id);}
s.bots.push(b);
event(s,'lifecycle',`${b.name} was adopted from the authoritative Playerbot world as a persistent AzerothAI resident.`,b.id);
}
const zone=(typeof payload.zoneName==='string'&&payload.zoneName.trim())?payload.zoneName.trim().slice(0,120):b.zone;
const level=typeof payload.level==='number'&&Number.isInteger(payload.level)&&payload.level>0&&payload.level<=100?payload.level:b.level;
b.autonomy={...(b.autonomy??{}),managed:true,characterGuid:characterGuid??b.autonomy?.characterGuid,accountId:accountId??b.autonomy?.accountId,observedAt:new Date().toISOString()};
if(b.status==='dead'){
if(kind==='player.resurrect'||kind==='player.repop'){
event(s,'error',`Authoritative server event attempted to return permanently dead resident ${b.name} to play. Hardcore identity remains closed.`,b.id);
await store.save(s,revision);
}
return {matched:true,botId:b.id,dead:true};
}
if(b.status==='retired')return {matched:true,botId:b.id,retired:true};
if(kind==='player.snapshot')observePresence(s,b,payload);
b.level=level;
if(zone)b.zone=zone;
if(kind==='player.login'){
if(!s.aleRosterAt)b.status='online';
b.simulationBand='foreground';
event(s,'lifecycle',`${b.name} logged into the authoritative AzerothCore world.`,b.id);
}else if(kind==='player.logout'){
if(!s.aleRosterAt)b.status='offline';
b.simulationBand='dormant';
event(s,'lifecycle',`${b.name} logged out of the authoritative AzerothCore world.`,b.id);
}else if(kind==='player.zone'||kind==='player.map'){
if(!s.aleRosterAt&&b.status==='offline')b.status='online';
b.simulationBand='foreground';
event(s,'lifecycle',`${b.name} is now in ${b.zone} (authoritative ALE observation).`,b.id);
}else if(kind==='player.snapshot'){
if(!s.aleRosterAt&&b.status==='offline')b.status='online';
if(b.simulationBand==='dormant')b.simulationBand='background';
}else if(kind==='player.level'){
const text=`I reached level ${b.level} while living in ${b.zone}.`;
b.memories.push({id:crypto.randomUUID(),text,importance:8,source:'observation',at:new Date().toISOString()});
b.memories=b.memories.slice(-100);
event(s,'lifecycle',`${b.name} reached level ${b.level}.`,b.id);
}else if(kind==='player.quest.accept'){
const quest=typeof payload.quest==='string'?payload.quest.slice(0,160):'a new quest';
b.memories.push({id:crypto.randomUUID(),text:`I accepted ${quest} in ${b.zone}.`,importance:5,source:'observation',at:new Date().toISOString()});
b.memories=b.memories.slice(-100);
event(s,'memory',`${b.name} accepted a quest in ${b.zone}.`,b.id);
}else if(kind==='player.achievement'){
const achievement=typeof payload.achievement==='string'?payload.achievement.slice(0,160):'an achievement';
b.memories.push({id:crypto.randomUUID(),text:`I earned ${achievement}.`,importance:7,source:'observation',at:new Date().toISOString()});
b.memories=b.memories.slice(-100);
event(s,'memory',`${b.name} earned an achievement.`,b.id);
}else if(kind==='player.combat.enter'){
b.simulationBand='foreground';
event(s,'system',`${b.name} entered combat in ${b.zone}.`,b.id);
}else if(kind==='player.combat.leave'){
event(s,'system',`${b.name} left combat in ${b.zone}.`,b.id);
}else if(kind==='player.close-call'){
const text=`I barely survived a fight in ${b.zone}. I came close enough to death that I need to take danger more seriously.`;
const recent=b.memories.some(m=>m.source==='observation'&&m.text===text&&Date.now()-Date.parse(m.at)<60_000);
if(!recent){
b.memories.push({id:crypto.randomUUID(),text,importance:10,source:'observation',at:new Date().toISOString()});
b.memories=b.memories.slice(-100);
}
event(s,'memory',`${b.name} survived a close call in ${b.zone}; the experience was committed to memory.`,b.id);
}else if(kind==='player.killed-by-creature'||kind==='player.permanent-death'){
if(b.plotArmorHpFloor>=1){
event(s,'error',`Plot armor breach: authoritative ALE reported ${b.name} dead. Worldserver damage enforcement must prevent this transition.`,b.id);
}else if(s.settings.hardcore.enabled){
recordDeath(s,b,{
id:typeof payload.deathId==='string'?payload.deathId:crypto.randomUUID(),
at:new Date().toISOString(),
zone:b.zone,
level:b.level,
cause:'Killed in combat',
killer:typeof payload.killer==='string'?payload.killer.slice(0,120):undefined,
source:'azerothcore',
verified:true
});
}else{
event(s,'lifecycle',`${b.name} died in-game; standard resurrection rules remain active.`,b.id);
}
}
await store.save(s,revision);
return {matched:true,botId:b.id,status:b.status,zone:b.zone,level:b.level,characterGuid:b.autonomy?.characterGuid,accountId:b.autonomy?.accountId};
}
export async function execute(store:StateStore,input:unknown,config:RuntimeConfig={}) {
const a=actionSchema.parse(input);let s=await store.load();
const connector=connectors[s.settings.connector](config);
if(a.type==='health')return {health:await connector.health()};
const active=Object.values(s.operations).find(o=>Date.now()-o.started<60000);
if(active)throw new Error('A realm operation is in progress. Wait for it to finish.');
if(Object.keys(s.operations).length){s.operations={};event(s,'error','An interrupted operation expired. Verify live server state before retrying.');}
const revision=s.revision;
if(a.type==='gm'){
const id=crypto.randomUUID();s.operations[id]={started:Date.now(),botId:'gm'};event(s,'gm',`${a.live?'LIVE':'SIMULATED'} command requested: ${a.command}`);await store.save(s,revision);
try{const result=await runGM(a.command,a.live,a.confirmed,config);delete s.operations[id];event(s,'gm',`${a.live?'LIVE':'SIMULATED'} ${result.command}: ${result.output}`);await store.save(s,s.revision);return result;}catch(e){delete s.operations[id];event(s,'error',`GM command failed: ${e instanceof Error?e.message:'Unknown error'}`);await store.save(s,s.revision);throw e;}
}else if(a.type==='retentionPolicy'){
s.opsRetentionDays=a.days;
}else if(a.type==='communityPolicy'){
Object.assign(community(s),{partiesEnabled:a.partiesEnabled,guildsEnabled:a.guildsEnabled});
}else if(a.type==='guildGoal'){
const g=community(s).guilds.find(g=>g.id===a.guildId);if(!g)throw new Error('Guild not observed');g.goal=a.goal;g.progress=0;
socialEntry(s,{id:crypto.randomUUID(),at:new Date().toISOString(),kind:'guild',names:[g.name],text:'Guild goal changed to '+a.goal,status:'confirmed'});
}else if(a.type==='director'){
s.director={experience:a.experience,zone:a.zone,playerName:a.playerName,brief:a.brief};
for(const b of s.bots.filter(b=>b.status!=='retired'&&b.status!=='dead')) b.goal=`${a.experience} in ${a.zone}${a.playerName?' around '+a.playerName:''}: ${a.brief}`.slice(0,200);
event(s,'system',`Realm direction updated: ${a.experience} in ${a.zone}. Living bot goals updated; movement remains the connector's responsibility.`);
}else if(a.type==='createBot'){
if(s.bots.length>=100)throw new Error('Prototype limit: 100 bot identities');
if(s.bots.some(b=>b.name.toLowerCase()===a.name.toLowerCase()))throw new Error('That name is already in use');
const p=s.recipes.find(r=>r.id===a.recipeId);if(!p)throw new Error('Recipe not found');
const newBot:Bot={id:crypto.randomUUID(),name:a.name,role:a.role,race:a.race,level:1,zone:s.director.zone,status:'offline',tier:'resident',mortal:true,plotArmorHpFloor:0,simulationBand:'dormant',routine:{wakeHour:7,sleepHour:23,preferredZones:[s.director.zone]},recipeId:p.id,recipeVersion:p.version,personality:structuredClone(p),goal:a.goal,memories:[],relationships:[],decisions:0};
const home=chooseHomeForNewResident(s,newBot);
if(home){newBot.homeId=home.id;home.residentIds.push(newBot.id);newBot.memories.push({id:crypto.randomUUID(),text:`I live at ${home.name} in ${home.zone}. It is the place I return to.`,importance:8,source:'simulation',at:new Date().toISOString()});}
s.bots.push(newBot);
event(s,'lifecycle',`${a.name} created as a mortal resident${home?` with a home at ${home.name}`:''}. Ready to enter the world.`);
}else if(a.type==='recordDeath'){
if(!s.settings.hardcore.enabled)throw new Error('Hardcore mode is disabled. Permanent death recording is unavailable.');
const b=botById(s,a.botId);
if(b.status==='dead')throw new Error(`${b.name} is already permanently dead`);
recordDeath(s,b,{id:crypto.randomUUID(),at:new Date().toISOString(),zone:a.zone||b.zone,level:b.level,cause:a.cause,killer:a.killer,source:'operator',verified:true});
}else if(a.type==='worldTrigger'){
const trigger={id:crypto.randomUUID(),kind:a.kind,zone:a.zone,playerName:a.playerName,detail:a.detail,at:new Date().toISOString()};
const plan=runPopulationDirector(s,trigger);
s.population.lastPlan=plan;
if(s.settings.connector==='simulation')applySimulationPopulationPlan(s,plan);
const names=plan.selectedBotIds.map(id=>s.bots.find(b=>b.id===id)?.name).filter(Boolean).join(', ');
event(s,'population',`World trigger ${a.kind} in ${a.zone}: requested ${plan.requested}, capacity ${plan.capacity}, activated/planned ${plan.selectedBotIds.length}${names?` (${names})`:''}. No synthetic identities were created.`);
}else if(a.type==='assignHome'){
const b=botById(s,a.botId);
if(b.status==='dead'||b.status==='retired')throw new Error('Only living active identities can move home.');
const home=s.homes.find(h=>h.id===a.homeId);if(!home)throw new Error('Home not found');
if(!home.residentIds.includes(b.id)&&home.residentIds.length>=home.capacity)throw new Error('That home is at capacity');
for(const h of s.homes)h.residentIds=h.residentIds.filter(id=>id!==b.id);
home.residentIds.push(b.id);b.homeId=home.id;
b.memories.push({id:crypto.randomUUID(),text:`I moved to ${home.name} in ${home.zone}. This is home now.`,importance:8,source:'operator',at:new Date().toISOString()});b.memories=b.memories.slice(-100);
event(s,'housing',`${b.name} now lives at ${home.name} in ${home.zone}.`,b.id);
}else if(a.type==='saveRecipe'){
const old=s.recipes.find(r=>r.id===a.recipe.id);if(a.recipe.id&&!old)throw new Error('Recipe not found');
if(old&&a.recipe.version!==old.version)throw new Error('Recipe changed. Reopen it before saving.');
if(!old&&s.recipes.length>=100)throw new Error('Prototype limit: 100 recipes');
const recipe={...a.recipe,id:old?.id??crypto.randomUUID(),version:(old?.version??0)+1};
s.recipes=old?s.recipes.map(r=>r.id===old.id?recipe:r):[...s.recipes,recipe];event(s,'recipe',`${recipe.name} saved as version ${recipe.version}. Existing bots retain their snapshot.`);
}else if(a.type==='settings'){
if(a.connector!==s.settings.connector&&s.bots.some(b=>b.status==='online'||b.status==='paused'))throw new Error('Despawn all online and paused bots before switching connectors.');
if(a.connector==='azerothcore'&&(!config.BRIDGE_URL||!config.BRIDGE_TOKEN))throw new Error('AzerothCore bridge is not configured on the server.');
if(a.provider!=='deterministic'&&!config.LLM_URL)throw new Error('Local LLM endpoint is not configured on the server.');
s.settings={connector:a.connector,provider:a.provider,model:a.model,brains:a.brains,hardcore:a.hardcore,population:a.population};
event(s,'system',`Realm connector and model settings updated. Hardcore one-life mode is ${a.hardcore.enabled?'enabled':'disabled'}.`);
}else{
const b=botById(s,a.botId);
if(b.status==='dead')throw new Error(`${b.name} is permanently dead. This identity cannot act, spawn, resume, or be recycled.`);
if(a.type==='memory'){
b.memories.push({id:crypto.randomUUID(),text:a.text,importance:a.importance,source:'operator',at:new Date().toISOString()});b.memories=b.memories.slice(-100);event(s,'memory',`${b.name} received an operator memory.`,b.id);
}else if(a.type==='relationship'){
botById(s,a.targetId);if(a.targetId===b.id)throw new Error('A bot cannot have a relationship with itself');
b.relationships=[...b.relationships.filter(r=>r.targetId!==a.targetId),{targetId:a.targetId,affinity:a.affinity,note:a.note}];event(s,'relationship',`${b.name}'s relationship updated.`,b.id);
}else if(a.type==='applyRecipe'){
const p=s.recipes.find(r=>r.id===a.recipeId);if(!p)throw new Error('Recipe not found');b.personality=structuredClone(p);b.recipeId=p.id;b.recipeVersion=p.version;event(s,'recipe',`${b.name} now uses ${p.name} v${p.version}.`,b.id);
}else if(a.type==='lifecycle'||a.type==='think'){
const transitions:Record<string,{from:Status[];to:Status}>={spawn:{from:['offline'],to:'online'},pause:{from:['online'],to:'paused'},resume:{from:['paused'],to:'online'},despawn:{from:['online','paused'],to:'offline'},retire:{from:['offline'],to:'retired'}};
if(a.type==='think'&&b.status!=='online')throw new Error('Only online bots can make decisions');
if(a.type==='lifecycle'&&!transitions[a.action].from.includes(b.status))throw new Error(`Cannot ${a.action} a ${b.status} bot`);
const requestId=crypto.randomUUID();s.operations[requestId]={started:Date.now(),botId:b.id};await store.save(s,revision);
try{
if(a.type==='lifecycle'){
await connector.lifecycle(b,a.action,requestId);
b.status=transitions[a.action].to;if(a.action==='spawn'){b.zone=s.settings.connector==='simulation'?s.director.zone:'Awaiting observation';b.simulationBand='foreground';}if(a.action==='despawn'||a.action==='retire')b.simulationBand='dormant';if(a.action==='pause')b.simulationBand='background';if(a.action==='resume')b.simulationBand='foreground';event(s,'lifecycle',`${b.name}: ${a.action} acknowledged (${s.settings.connector}).`,b.id);
}else{
const observation=await connector.observe(b);
if(observedHardcoreDeath(s,b,observation)){
delete s.operations[requestId];
await store.save(s,s.revision);
return {ok:true,dead:true};
}
const brain=providerForBot(b,s.settings,config);
const decision=await brain.provider.decide(b,observation);
event(s,'system',`${b.name} brain route: ${brain.route.provider} · ${brain.route.model} · ${brain.route.reason}.`,b.id);
const result=await connector.decide(b,decision,requestId);
b.lastDecision=decision;b.decisions++;b.goal=decision.goal;b.zone=result.zone??observation.zone;b.level=observation.level;
b.memories.push({id:crypto.randomUUID(),text:result.summary,importance:5,source:s.settings.connector==='simulation'?'simulation':'observation',at:new Date().toISOString()});b.memories=b.memories.slice(-100);
event(s,'decision',result.summary,b.id);
}
}catch(err){
delete s.operations[requestId];event(s,'error',`${b.name}: operation failed; no local lifecycle change. ${err instanceof Error?err.message:'Unknown error'}`,b.id);await store.save(s,s.revision);throw err;
}
delete s.operations[requestId];await store.save(s,s.revision);return {ok:true};
}
}
await store.save(s,revision);return {ok:true};
}