diff --git a/app/api/health/route.ts b/app/api/health/route.ts
index abad6e9..8773274 100644
--- a/app/api/health/route.ts
+++ b/app/api/health/route.ts
@@ -15,7 +15,8 @@ export async function GET(){
ok:true,
aleActiveRecently,
lastAleAt,
- bridgeConfigured:!!(c.BRIDGE_URL&&c.BRIDGE_TOKEN),
+ gameBackend:c.GAME_BACKEND??'azerothcore',
+ bridgeConfigured:c.GAME_BACKEND==='cmangos-classic'?!!c.ALE_BRIDGE_TOKEN:!!(c.BRIDGE_URL&&c.BRIDGE_TOKEN),
aleConfigured:!!c.ALE_BRIDGE_TOKEN,
aleOperatorConfigured:!!c.ALE_OPERATOR_TOKEN,
aleRawEnabled:rawAleEnabled(c),
diff --git a/app/page.tsx b/app/page.tsx
index 84f79e5..74655f4 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -63,12 +63,12 @@ export default function Home({readOnly=false}:{readOnly?:boolean}){
AzerothAI shapes identity and intent. The game server owns the rules of the world.
Create a character Start with an identity and recipe. Spawn when you’re ready. {state&&setCreateOpen(false)}/>}
{readOnly?'Bot recipe':recipe?'Edit bot recipe':'Create bot recipe'} Traits guide high-level decisions. They never override game rules. setRecipeOpen(false)}/>
- {if(!o)setBotId(null)}}>Bot profile Identity, intent, and the story so far. {selected&&state&&}
+ {if(!o)setBotId(null)}}>Bot profile Identity, intent, and the story so far. {selected&&state&&}
{if(!o)setConfirm(null)}}>{confirm?.title} {confirm?.description} Cancel {if(confirm)perform(confirm.action);setConfirm(null)}}>Confirm ;
}
function CreateBot({state,act,busy,close}:any){const [name,setName]=useState(''),[role,setRole]=useState('Priest'),[race,setRace]=useState('Human'),[recipeId,setRecipeId]=useState(state.recipes[0].id),[goal,setGoal]=useState(`Support ${state.director.experience.toLowerCase()} in ${state.director.zone}`);return }
function RecipeEditor({recipe,act,busy,close}:any){const [r,setR]=useState(recipe??{name:'',description:'',tags:['Curious'],sociability:50,aggression:20,curiosity:70,economy:30,activities:['explore','quest']});const [tags,setTags]=useState(r.tags.join(', '));return {e.preventDefault();try{await act({type:'saveRecipe',recipe:{...r,tags:tags.split(',').map(t=>t.trim()).filter(Boolean)}});toast.success('Recipe saved');close();}catch{}}}>Recipe name setR({...r,name:e.target.value})}/> Personality descriptionsetR({...r,description:e.target.value})}/> Tags, separated by commas setTags(e.target.value)}/>
{(['sociability','aggression','curiosity','economy'] as const).map(key=>
{key} {r[key]}
setR({...r,[key]:v[0]})}/> )}
Preferred activities {ACTIVITIES.map(a=>setR({...r,activities:v?[...r.activities,a]:r.activities.filter(x=>x!==a)})}/>{a} )}
{recipe&&{try{const {id,version,...copy}=r;void id;void version;await act({type:'saveRecipe',recipe:{...copy,name:`${copy.name} copy`.slice(0,80)}});close();}catch{}}}>Duplicate }Save recipe
}
-function BotProfile({bot:b,state,act,busy,perform,confirm,readOnly=false}:any){const [memory,setMemory]=useState(''),[importance,setImportance]=useState(7),[target,setTarget]=useState(state.bots.find((x:Bot)=>x.id!==b.id)?.id??''),[affinity,setAffinity]=useState(40),[note,setNote]=useState(''),[recipeId,setRecipeId]=useState(b.recipeId);const actions=b.status==='online'?['pause','despawn']:b.status==='paused'?['resume','despawn']:b.status==='offline'?['spawn','retire']:[];return <>{b.name} Level {b.level} {b.race} {b.role}
{b.status} {actions.map(a=>
a==='retire'?confirm({title:`Retire ${b.name}?`,description:'The identity and memories remain in your realm, but this bot cannot spawn again in this prototype.',action:{type:'lifecycle',botId:b.id,action:a}}):perform({type:'lifecycle',botId:b.id,action:a})}>{a==='pause'?:a==='spawn'||a==='resume'?:null}{a.charAt(0).toUpperCase()+a.slice(1)} )}
perform({type:'think',botId:b.id})}>{busy?'Working…':'Run decision'} Identity Memory Relationships Current goal {b.goal}
{b.zone}
{b.personality.name} v{b.recipeVersion} {b.personality.description}
{b.personality.tags.map((t:string)=>{t} )}
({value:r.id,label:`${r.name} · v${r.version}`}))}/>perform({type:'applyRecipe',botId:b.id,recipeId})}>Apply current recipe version Latest decision {b.lastDecision?`${b.lastDecision.action}: ${b.lastDecision.reason}`:'No decisions yet. Run a decision while this bot is online.'}
{b.decisions} decisions acknowledged {e.preventDefault();try{await act({type:'memory',botId:b.id,text:memory,importance});setMemory('');}catch{}}}>Add a memorysetMemory(e.target.value)} placeholder="What should this character remember?"/> Importance {importance}/10
setImportance(v[0])}/> Save memory {[...b.memories].reverse().map((m:any)=>{m.text}
{m.source} · Importance {m.importance}/10 · {new Date(m.at).toLocaleDateString()} )}Bonds are directional. One character’s trust does not imply the other’s.
{e.preventDefault();try{await act({type:'relationship',botId:b.id,targetId:target,affinity,note});setNote('');}catch{}}}>Other character x.id!==b.id).map((x:Bot)=>({value:x.id,label:x.name}))}/>
Affinity {affinity>0?'+':''}{affinity}
setAffinity(v[0])}/> Why this bond existssetNote(e.target.value)}/> Save relationship {b.relationships.map((r:any)=>{state.bots.find((x:Bot)=>x.id===r.targetId)?.name} {r.affinity>0?'+':''}{r.affinity} {r.note}
)} >}
+function BotProfile({bot:b,state,act,busy,perform,confirm,readOnly=false,fixedRoster=false}:any){const [memory,setMemory]=useState(''),[importance,setImportance]=useState(7),[target,setTarget]=useState(state.bots.find((x:Bot)=>x.id!==b.id)?.id??''),[affinity,setAffinity]=useState(40),[note,setNote]=useState(''),[recipeId,setRecipeId]=useState(b.recipeId);const actions=fixedRoster?[]:b.status==='online'?['pause','despawn']:b.status==='paused'?['resume','despawn']:b.status==='offline'?['spawn','retire']:[];return <>{b.name} Level {b.level} {b.race} {b.role}
{b.status} {actions.map(a=>
a==='retire'?confirm({title:`Retire ${b.name}?`,description:'The identity and memories remain in your realm, but this bot cannot spawn again in this prototype.',action:{type:'lifecycle',botId:b.id,action:a}}):perform({type:'lifecycle',botId:b.id,action:a})}>{a==='pause'?:a==='spawn'||a==='resume'?:null}{a.charAt(0).toUpperCase()+a.slice(1)} )}
perform({type:'think',botId:b.id})}>{busy?'Working…':'Run decision'} Identity Memory Relationships Current goal {b.goal}
{b.zone}
{b.personality.name} v{b.recipeVersion} {b.personality.description}
{b.personality.tags.map((t:string)=>{t} )}
({value:r.id,label:`${r.name} · v${r.version}`}))}/>perform({type:'applyRecipe',botId:b.id,recipeId})}>Apply current recipe version Latest decision {b.lastDecision?`${b.lastDecision.action}: ${b.lastDecision.reason}`:'No decisions yet. Run a decision while this bot is online.'}
{b.decisions} decisions acknowledged {e.preventDefault();try{await act({type:'memory',botId:b.id,text:memory,importance});setMemory('');}catch{}}}>Add a memorysetMemory(e.target.value)} placeholder="What should this character remember?"/> Importance {importance}/10
setImportance(v[0])}/> Save memory {[...b.memories].reverse().map((m:any)=>{m.text}
{m.source} · Importance {m.importance}/10 · {new Date(m.at).toLocaleDateString()} )}Bonds are directional. One character’s trust does not imply the other’s.
{e.preventDefault();try{await act({type:'relationship',botId:b.id,targetId:target,affinity,note});setNote('');}catch{}}}>Other character x.id!==b.id).map((x:Bot)=>({value:x.id,label:x.name}))}/>
Affinity {affinity>0?'+':''}{affinity}
setAffinity(v[0])}/> Why this bond existssetNote(e.target.value)}/> Save relationship {b.relationships.map((r:any)=>{state.bots.find((x:Bot)=>x.id===r.targetId)?.name} {r.affinity>0?'+':''}{r.affinity} {r.note}
)} >}
function WorldMonitor({state,monitor,capabilities,openBot}:any){
const living:Bot[]=state.bots.filter((b:Bot)=>b.status!=='dead'&&b.status!=='retired');
const active=living.filter((b:Bot)=>b.status==='online');
diff --git a/core/connectors/index.ts b/core/connectors/index.ts
index f86733e..74646f0 100644
--- a/core/connectors/index.ts
+++ b/core/connectors/index.ts
@@ -1,4 +1,7 @@
import { z } from 'zod';
+import {classicCommand} from '../classic';
+import {queueAleCommand} from '../ale-store';
+import {runGM} from '../gm';
import type { GameConnector,Bot,Decision,RuntimeConfig } from '../types';
const deathSignal=z.object({
@@ -45,5 +48,21 @@ export class AzerothCoreConnector implements GameConnector {
async decide(bot:Bot,decision:Decision,requestId:string){return z.object({accepted:z.literal(true),summary:z.string().max(1000),zone:z.string().max(120).optional()}).parse(await this.request(`/bots/${encodeURIComponent(bot.id)}/intent`,{decision,requestId}));}
}
+export class ClassicConnector implements GameConnector {
+ id='cmangos-classic';
+ constructor(private config:RuntimeConfig){}
+ async health(){const result=await runGM('server info',true,true,this.config);return {ok:true,detail:result.output};}
+ async observe(bot:Bot){
+ if(!bot.world||Date.now()/1000-bot.world.at>30)throw new Error('Fresh Classic observation required');
+ return {zone:bot.zone,level:bot.level,alive:!bot.world.isDead&&!bot.world.isGhost,availableActions:bot.personality.activities,summary:JSON.stringify({zone:bot.zone,level:bot.level,health:bot.world.healthPct,mana:bot.world.manaPct,quests:bot.world.quests,combat:bot.world.inCombat,party:bot.world.groupNames})};
+ }
+ async lifecycle(){throw new Error('Classic maintains the fixed 50-character roster automatically. Manual lifecycle changes are not enabled.');}
+ async decide(bot:Bot,decision:Decision){
+ if(!this.config.DB)throw new Error('Classic command database is unavailable');
+ const queued=await queueAleCommand(this.config.DB,classicCommand('intent',bot,decision.action));
+ return {summary:`${bot.name}: requested ${decision.action}; native receipt and observed outcome are pending (${queued.id}).`,zone:bot.zone};
+ }
+}
+
// Add a connector factory here; personality and memory code remains game-independent.
-export const connectors:RecordGameConnector>={simulation:()=>new SimulationConnector(),azerothcore:c=>new AzerothCoreConnector(c)};
+export const connectors:RecordGameConnector>={simulation:()=>new SimulationConnector(),azerothcore:c=>c.GAME_BACKEND==='cmangos-classic'?new ClassicConnector(c):new AzerothCoreConnector(c)};
diff --git a/core/gm.ts b/core/gm.ts
index d7b367b..49cfc33 100644
--- a/core/gm.ts
+++ b/core/gm.ts
@@ -12,7 +12,7 @@ export async function runGM(raw:string,live:boolean,confirmed:boolean,config:Run
if(!live)return {command,output:`[SIMULATED] Accepted: ${command}\nNo command was sent to a game server.`};
if(!confirmed)throw new Error('Confirm the live GM command before execution');
if(!config.SOAP_URL||!config.SOAP_USER||!config.SOAP_PASSWORD)throw new Error('Configure SOAP_URL, SOAP_USER and SOAP_PASSWORD on the server first');
- const r=await fetch(config.SOAP_URL,{method:'POST',headers:{'Content-Type':'text/xml; charset=utf-8',Authorization:`Basic ${btoa(`${config.SOAP_USER}:${config.SOAP_PASSWORD}`)}`},body:`${escapeXML(command)} `,signal:AbortSignal.timeout(12000),redirect:'error'});
+ const r=await fetch(config.SOAP_URL,{method:'POST',headers:{'Content-Type':'text/xml; charset=utf-8',Authorization:`Basic ${btoa(`${config.SOAP_USER}:${config.SOAP_PASSWORD}`)}`},body:`${escapeXML(command)} `,signal:AbortSignal.timeout(12000),redirect:'manual'});
const xml=await r.text();if(!r.ok||/<(?:\w+:)?Fault\b/.test(xml))throw new Error(`SOAP command failed (HTTP ${r.status}). Check the worldserver log.`);
const output=xml.match(/<(?:\w+:)?result(?:\s[^>]*)?>([\s\S]*?)<\/(?:\w+:)?result>/)?.[1];
if(output===undefined)throw new Error('Unexpected SOAP response; check the worldserver before retrying');
diff --git a/core/service.ts b/core/service.ts
index ececda0..e75735c 100644
--- a/core/service.ts
+++ b/core/service.ts
@@ -296,7 +296,7 @@ export async function execute(store:StateStore,input:unknown,config:RuntimeConfi
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.connector==='azerothcore'&&config.GAME_BACKEND!=='cmangos-classic'&&(!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'}.`);
diff --git a/core/types.ts b/core/types.ts
index eb9b635..69ce48e 100644
--- a/core/types.ts
+++ b/core/types.ts
@@ -29,4 +29,4 @@ export type Observation = {zone:string; level:number; availableActions:Activity[
export interface GameConnector {id:string; health():Promise<{ok:boolean; detail:string}>; observe(bot:Bot):Promise; lifecycle(bot:Bot,action:'spawn'|'pause'|'resume'|'despawn'|'retire',requestId:string):Promise; decide(bot:Bot,decision:Decision,requestId:string):Promise<{summary:string;zone?:string}>;}
export interface LLMProvider {id:string; decide(bot:Bot,observation:Observation):Promise}
export interface StateStore {load():Promise; save(state:State,expectedRevision:number):Promise}
-export type RuntimeConfig = {GAME_BACKEND?:string;SOAP_URL?:string; SOAP_USER?:string; SOAP_PASSWORD?:string; BRIDGE_URL?:string; BRIDGE_TOKEN?:string; ALE_BRIDGE_TOKEN?:string; ALE_OPERATOR_TOKEN?:string; ALE_RAW_EXEC_ENABLED?:string; AUTONOMY_TOKEN?:string; LLM_URL?:string; LLM_API_KEY?:string};
+export type RuntimeConfig = {DB?:D1Database;GAME_BACKEND?:string;SOAP_URL?:string; SOAP_USER?:string; SOAP_PASSWORD?:string; BRIDGE_URL?:string; BRIDGE_TOKEN?:string; ALE_BRIDGE_TOKEN?:string; ALE_OPERATOR_TOKEN?:string; ALE_RAW_EXEC_ENABLED?:string; AUTONOMY_TOKEN?:string; LLM_URL?:string; LLM_API_KEY?:string};