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,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){ let s=await store.load(); const revision=s.revision; 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:'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}; b.level=level; if(zone)b.zone=zone; if(kind==='player.login'){ b.status='online'; b.simulationBand='foreground'; event(s,'lifecycle',`${b.name} logged into the authoritative AzerothCore world.`,b.id); }else if(kind==='player.logout'){ 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(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(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'){ 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==='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={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}; }