69 lines
4.4 KiB
TypeScript
69 lines
4.4 KiB
TypeScript
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({
|
|
id:z.string().min(1).max(160),
|
|
at:z.string().min(1).max(80),
|
|
cause:z.string().min(1).max(300),
|
|
killer:z.string().max(120).optional(),
|
|
zone:z.string().max(120).optional(),
|
|
verified:z.boolean()
|
|
}).strict();
|
|
|
|
const observation=z.object({
|
|
zone:z.string().max(120),
|
|
level:z.number().int().min(1).max(100),
|
|
summary:z.string().max(2000),
|
|
availableActions:z.array(z.enum(['explore','quest','socialize','trade','rest'])),
|
|
alive:z.boolean().default(true),
|
|
death:deathSignal.optional()
|
|
});
|
|
|
|
export class SimulationConnector implements GameConnector {
|
|
id='simulation';
|
|
async health(){return {ok:true,detail:'Simulation ready. No AzerothCore connection.'};}
|
|
async observe(bot:Bot){return {zone:bot.zone,level:bot.level,availableActions:bot.personality.activities,summary:'Simulated world observation. Game mechanics are not executed.',alive:bot.status!=='dead'};}
|
|
async lifecycle(){ /* Acknowledged only inside the simulation. */ }
|
|
async decide(bot:Bot,d:Decision){return {summary:`${bot.name} chose to ${d.action}: ${d.goal}`,zone:bot.zone};}
|
|
}
|
|
|
|
export class AzerothCoreConnector implements GameConnector {
|
|
id='azerothcore';
|
|
constructor(private config:RuntimeConfig){}
|
|
private async request(path:string,body?:unknown){
|
|
if(!this.config.BRIDGE_URL||!this.config.BRIDGE_TOKEN)throw new Error('Configure BRIDGE_URL and BRIDGE_TOKEN on the server first.');
|
|
const url=new URL(this.config.BRIDGE_URL); if(!['http:','https:'].includes(url.protocol))throw new Error('Invalid bridge URL');
|
|
const response=await fetch(`${url.href.replace(/\/$/,'')}/v1${path}`,{method:body?'POST':'GET',headers:{Authorization:`Bearer ${this.config.BRIDGE_TOKEN}`,'Content-Type':'application/json'},...(body?{body:JSON.stringify(body)}:{}),signal:AbortSignal.timeout(12000),redirect:'error'});
|
|
if(!response.ok)throw new Error(`Game bridge returned HTTP ${response.status}`); return response.json();
|
|
}
|
|
async health(){const r=z.object({ok:z.boolean(),detail:z.string().max(300)}).parse(await this.request('/health'));return r;}
|
|
async observe(bot:Bot){return observation.parse(await this.request(`/bots/${encodeURIComponent(bot.id)}/observation`));}
|
|
async lifecycle(bot:Bot,action:string,requestId:string){
|
|
const r=z.object({accepted:z.literal(true)}).parse(await this.request(`/bots/${encodeURIComponent(bot.id)}/lifecycle`,{action,identity:{id:bot.id,name:bot.name,race:bot.race,role:bot.role,tier:bot.tier,mortal:bot.mortal,plotArmorHpFloor:bot.plotArmorHpFloor},requestId}));
|
|
void r;
|
|
}
|
|
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:Record<string,(config:RuntimeConfig)=>GameConnector>={simulation:()=>new SimulationConnector(),azerothcore:c=>c.GAME_BACKEND==='cmangos-classic'?new ClassicConnector(c):new AzerothCoreConnector(c)};
|