{capabilities.aleActiveRecently?'GAME SERVER LIVE':state?.settings.connector==='azerothcore'?'Native game bridge':'SIMULATION REALM'}{readOnly?'Visitor · Read only':'GM workspace'}
{page==='GM Console'?'THE WORLD IS YOUR SANDBOX':'AZEROTHAI CONTROL PLANE'}
{page==='Dashboard'?'Your realm, alive.':page}
{({'Dashboard':'The people, personalities, and stories that make your world.','World Monitor':'Watch Azeroth breathe: population, activity, systems, and the lives unfolding right now.','Realm Director':'Shape the adventure around you. Let your characters bring it to life.','World & Housing':'Homes, routines, simulation bands, and believable population flow.','Bots':'Give every character a purpose, then let their story unfold.','Bot Recipes':'Same model. Different personalities. A world of possibilities.','Memory & Bonds':'A shared history makes the next encounter matter.','Event Stream':'Follow the decisions and changes happening across your realm.','GM Console':'Pull up a chair. Put on the blue robe. Run your world.','Server & Models':'Connect the game engine and the minds behind your characters.'} as any)[page]}
setS({...s,connector})} items={[{value:'simulation',label:'Simulation · Ready to use'},{value:'azerothcore',label:'Live game server · Native bridge'}]}/>
Game bridge configured{capabilities.bridgeConfigured?'Yes':'Not yet'}
ALE control plane{capabilities.aleConfigured?'Configured':'Not yet'}
setS({...s,provider})} items={[{value:'deterministic',label:'Personality simulator · No model needed'},{value:'ollama',label:'Ollama · Local inference'},{value:'openai-compatible',label:'OpenAI-compatible endpoint'}]}/>
setBrains({enabled})}/>
setBrains({backgroundUseLLM})}/>
Local model endpoint configured{capabilities.llmConfigured?'Yes':'Not yet'}
Default: Qwen3.5 4B for residents and Zaku. Background residents stay on deterministic utility logic unless enabled here, saving CPU for foreground conversations and important decisions.
Movement → combat → inventory → world rules → authoritative events
}
-function GM({state,act,busy,capabilities,setConfirm}:any){const [command,setCommand]=useState('.server info'),[live,setLive]=useState(false),[history,setHistory]=useState([]),[index,setIndex]=useState(-1);const gmEvents=state.events.filter((e:RealmEvent)=>e.kind==='gm'||e.kind==='error').slice(-30);const shortcuts=[['Server info','.server info'],['World announcement','.announce Travelers, gather at the gates of Stormwind!'],['Teleport character','.teleport name Yourname Stormwind'],['Character info','.pinfo Yourname'],['Message of the day','.server motd']];return
{live?'Commands will execute on your configured worldserver.':'Simulation mode. No connection to a real worldserver.'}
{gmEvents.length?gmEvents.map((e:RealmEvent)=>
{new Date(e.at).toLocaleTimeString()}
{e.message}
):
Type .server info to get started.
}
{shortcuts.map(([label,cmd])=>)}
Live commands use AzerothCore SOAP and require confirmation. Supported commands are limited to this toolbox; bot models cannot issue them.
In-game commands such as .gm on, .gm visible off, and .summon need your logged-in GM character. This web console has no character position or selected target.
The first prototype provides remote announcements, named teleports, and server/player information. More GM powers can be added explicitly.
}
+function GM({state,act,busy,capabilities,setConfirm}:any){const [command,setCommand]=useState('.server info'),[live,setLive]=useState(false),[history,setHistory]=useState([]),[index,setIndex]=useState(-1);const gmEvents=state.events.filter((e:RealmEvent)=>e.kind==='gm'||e.kind==='error').slice(-30);const shortcuts=[['Server info','.server info'],['World announcement','.announce Travelers, gather at the gates of Stormwind!'],['Teleport character','.teleport name Yourname Stormwind'],['Character info','.pinfo Yourname'],['Message of the day','.server motd']];return
{live?'Commands will execute on your configured worldserver.':'Simulation mode. No connection to a real worldserver.'}
{gmEvents.length?gmEvents.map((e:RealmEvent)=>
{new Date(e.at).toLocaleTimeString()}
{e.message}
):
Type .server info to get started.
}
{shortcuts.map(([label,cmd])=>)}
Live commands use the configured game server and require confirmation. Supported commands are limited to this toolbox; bot models cannot issue them.
In-game commands such as .gm on, .gm visible off, and .summon need your logged-in GM character. This web console has no character position or selected target.
The first prototype provides remote announcements, named teleports, and server/player information. More GM powers can be added explicitly.
}
diff --git a/core/classic.ts b/core/classic.ts
index df8340b..53edca7 100644
--- a/core/classic.ts
+++ b/core/classic.ts
@@ -9,3 +9,9 @@ export function classicCommand(kind:'party'|'leave'|'recruit'|'intent'|'say',bot
if(['party','recruit','say'].includes(kind)&&(!peer?.autonomy?.managed||!peer.autonomy.characterGuid||!peer.autonomy.accountId))throw new Error('Verified Classic peer required');
return JSON.stringify({version:1,backend:'cmangos-classic',kind,guid:identity.characterGuid,accountId:identity.accountId,name:bot.name,argument,peerGuid:peer?.autonomy?.characterGuid??0,peerAccountId:peer?.autonomy?.accountId??0,peerName:peer?.name??'',expiresAt:Math.floor(Date.now()/1000)+90});
}
+
+export function classicPlayerReply(bot:Bot,request:{speakerGuid?:number;speakerAccountId?:number;speakerName:string;channel:'say'|'whisper'},reply:string){
+ if(!Number.isInteger(request.speakerGuid)||!request.speakerGuid||request.speakerGuid<1||!Number.isInteger(request.speakerAccountId)||!request.speakerAccountId||request.speakerAccountId<1||!/^[A-Za-z]{2,12}$/.test(request.speakerName))throw new Error('Verified Classic human identity required');
+ const command=JSON.parse(classicCommand('intent',bot,'socialize'));
+ return JSON.stringify({...command,kind:request.channel==='whisper'?'reply-whisper':'reply-say',argument:reply,peerGuid:request.speakerGuid,peerAccountId:request.speakerAccountId,peerName:request.speakerName});
+}
diff --git a/core/conversation-store.ts b/core/conversation-store.ts
index 4fda294..f0eaf28 100644
--- a/core/conversation-store.ts
+++ b/core/conversation-store.ts
@@ -1,6 +1,8 @@
export type ConversationChannel='say'|'whisper';
export type ConversationRequest={
+ speakerGuid?:number;
+ speakerAccountId?:number;
socialTurn?:0|1|2|3;
id:string;
createdAt:string;
@@ -16,6 +18,7 @@ export type ConversationRequest={
};
async function ensure(db:D1Database){
+ await db.prepare('CREATE TABLE IF NOT EXISTS conversation_identity (id TEXT PRIMARY KEY,guid INTEGER NOT NULL,account_id INTEGER NOT NULL)').run();
await db.prepare(`CREATE TABLE IF NOT EXISTS conversation_requests (
id TEXT PRIMARY KEY NOT NULL,
created_at TEXT NOT NULL,
@@ -68,6 +71,9 @@ export async function queueConversationEvent(db:D1Database,kind:string,payload:R
text(payload,'selectedPlayer',40),
text(payload,'nearbyBots',1000)
).run();
+ if(Number.isInteger(payload.characterGuid)&&Number(payload.characterGuid)>0&&Number.isInteger(payload.accountId)&&Number(payload.accountId)>0){
+ await db.prepare('INSERT INTO conversation_identity VALUES (?,?,?)').bind(id,payload.characterGuid,payload.accountId).run();
+ }
return {id,createdAt};
}
@@ -82,6 +88,7 @@ export async function leaseConversation(db:D1Database):Promise();
if(!row)return null;
+ const identity=await db.prepare('SELECT guid,account_id FROM conversation_identity WHERE id=?').bind(row.id).first<{guid:number;account_id:number}>();
const startedAt=new Date().toISOString();
const updated=await db.prepare(`UPDATE conversation_requests
@@ -90,6 +97,7 @@ export async function leaseConversation(db:D1Database):Promises.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');
export async function runGM(raw:string,live:boolean,confirmed:boolean,config:RuntimeConfig){
- const command=parseGM(raw);
+ const parsed=parseGM(raw);
+ const command=config.GAME_BACKEND==='cmangos-classic'?parsed.replace(/^teleport name /,'tele name '):parsed;
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:'error'});
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 6b38688..ececda0 100644
--- a/core/service.ts
+++ b/core/service.ts
@@ -210,7 +210,7 @@ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,pa
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'){
+ }else if(kind==='player.killed-by-creature'||kind==='player.permanent-death'||kind==='player.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){
@@ -236,7 +236,10 @@ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,pa
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()};
+ if(a.type==='health'){
+ if(config.GAME_BACKEND==='cmangos-classic'){const result=await runGM('server info',true,true,config);return {health:{ok:true,detail:result.output}};}
+ 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.');}
diff --git a/deploy/classic/README.md b/deploy/classic/README.md
index cffc2a4..538d6fe 100644
--- a/deploy/classic/README.md
+++ b/deploy/classic/README.md
@@ -80,3 +80,17 @@ Migration safeguards:
not proof that corresponding game characters can safely be deleted.
- Test native bot login, progress, resurrection, safety rules, read-only visitor
access, and current observations before switching the live dashboard or client.
+
+Native outcome hooks now report quest completion/rewards, loot item counts,
+looted copper, deaths, resurrection and learned spells. These are separate from
+accepted intentions. The configured native auto-learn policy is invoked on
+managed level changes; this is not a claim of trainer purchases. Player say and
+whisper events bind the sender's native character/account identity and replies
+revalidate that identity. Basic GM commands use Classic's own SOAP namespace;
+Wrath-only Lua operator calls are rejected explicitly.
+
+`prepare-auth.py --root ROOT` prepares a private Classic service GM account,
+copies only ENDAMI's existing SRP credentials into the isolated authentication
+database, and disables upstream sample logins. `check-auth.py --root ROOT`
+verifies a complete SRP handshake and realm listing without entering the game.
+`health.py --root ROOT --url URL` reports host health without Wrath SQL.
diff --git a/deploy/classic/check-auth.py b/deploy/classic/check-auth.py
new file mode 100644
index 0000000..8330070
--- /dev/null
+++ b/deploy/classic/check-auth.py
@@ -0,0 +1,43 @@
+#!/usr/bin/env python3
+"""Verify Classic SRP login and realm listing without entering the game world."""
+import argparse,hashlib,json,pathlib,secrets,socket,struct
+
+def verify(host,port,credentials):
+ username=credentials['username'].upper().encode();password=credentials['password'].upper().encode()
+ def h(data):return hashlib.sha1(data).digest()
+ with socket.create_connection((host,port),timeout=8) as sock:
+ def read(n):
+ data=b''
+ while len(data)10000
+ status('Publishing Classic game and authentication ports')
+ run(['docker','stop','-t','30','azerothai-classic-world','azerothai-classic-auth'])
+ for name in ['azerothai-classic-world','azerothai-classic-auth']:run(['docker','rename',name,name+'-staging'])
+ common=['--network','azerothai-classic-access','--restart','unless-stopped','--user','1000:1000','-v',str(root)+':/work','-w','/work/run/bin']
+ run(['docker','create','--name','azerothai-classic-world',*common,'--cpus','2','--memory','3g','-p','8086:8086','azerothai-classic-builder:local','bash','-c','exec 3<>/work/run/console; exec ./mangosd -c /work/run/etc/mangosd.conf <&3'])
+ run(['docker','create','--name','azerothai-classic-auth',*common,'--cpus','0.5','--memory','128m','-p','3724:3724','azerothai-classic-builder:local','./realmd','-c','/work/run/etc/realmd.conf'])
+ for name in ['azerothai-classic-world','azerothai-classic-auth']:
+ run(['docker','network','connect','azerothai-classic',name]);run(['docker','start',name])
+ run(['docker','update','--restart','unless-stopped','azerothai-classic-db'])
+ ip=run(['docker','inspect','--format','{{with index .NetworkSettings.Networks "azerothai-classic-access"}}{{.IPAddress}}{{end}}','azerothai-classic-world'])
+ p=app/'.dev.vars';text=p.read_text();import re
+ text=re.sub(r'^SOAP_URL=.*$','SOAP_URL=http://'+ip+':7879',text,flags=re.M);p.write_text(text)
+ # Discard copied Wrath metrics; native Classic counters remain intact.
+ databases=list((app/'.wrangler/state/v3/d1').rglob('*.sqlite'))
+ assert len(databases)==1
+ db=sqlite3.connect(databases[0]);backup=sqlite3.connect(archive/'classic-before-cutover.sqlite');db.backup(backup);backup.close()
+ names=[x[0] for x in db.execute("SELECT name FROM sqlite_master WHERE type='table'")]
+ for name in names:
+ if name.startswith('insights_') or name=='operations_samples':db.execute('DELETE FROM "'+name+'"')
+ db.commit();db.close()
+ for name in classic:
+ p=units/(name+'.service');text=p.read_text().replace('8791','8787').replace('RUNTIME_HOST=127.0.0.1','RUNTIME_HOST=0.0.0.0');p.write_text(text)
+ p=root/'staging-worker.env';p.write_text(p.read_text().replace('8791','8787'))
+ p=units/'azerothai-spectator.service';p.write_text(p.read_text().replace('/home/endami/AzerothAI',str(app)).replace('After=network-online.target azerothai-runtime.service','After=network-online.target azerothai-classic-runtime.service'))
+ run(['systemctl','--user','daemon-reload']);run(['systemctl','--user','enable','--now',*classic,'azerothai-spectator'])
+ run(['docker','run','-d','--name','azerothai-dashboard-proxy','--restart','unless-stopped','--network','azerothai-mini','--add-host','host.docker.internal:host-gateway','--memory','128m','-p','8788:80','-v',str(home/'.config/azerothai/nginx.conf')+':/etc/nginx/conf.d/default.conf:ro','-v',str(app/'dist/client')+':/srv:ro','nginx:alpine'])
+ status('Classic endpoints active; waiting for all 50 fresh observations')
+ for _ in range(36):
+ try:
+ current=json.load(urllib.request.urlopen('http://127.0.0.1:8787/api/state',timeout=5));bs=current['bots']
+ if len(bs)==50 and all(b['status']=='online' and time.time()-b['world']['at']<20 for b in bs):break
+ except Exception:pass
+ time.sleep(5)
+ else:raise RuntimeError('Classic did not restore all 50 fresh observations')
+ assert sum(b['world']['team']==0 for b in bs)==25 and sum(b['world']['team']==1 for b in bs)==25
+ run(['python3',str(root/'adapter/check-auth.py'),'--root',str(root),'--port','3724'])
+ status('Classic cutover complete; 50 fresh bots, 25 Alliance and 25 Horde; login verified',cutover=True,backup=str(archive))
+ except Exception as error:
+ if changed:
+ run(['systemctl','--user','stop',*classic,'azerothai-spectator'],False)
+ run(['docker','stop','azerothai-classic-world','azerothai-classic-auth'],False)
+ if run(['docker','ps','-a','--filter','name=^azerothai-dashboard-proxy-wrath-rollback$','--format','{{.Names}}'],False):
+ run(['docker','rm','-f','azerothai-dashboard-proxy'],False);run(['docker','rename','azerothai-dashboard-proxy-wrath-rollback','azerothai-dashboard-proxy'],False)
+ for file in archive.glob('azerothai*'):shutil.copy2(file,units/file.name)
+ run(['systemctl','--user','daemon-reload'],False)
+ run(['docker','start','ac-worldserver','ac-authserver','azerothai-dashboard-proxy'],False)
+ run(['systemctl','--user','enable','--now',*old,'azerothai-spectator'],False)
+ status('Classic cutover blocked; former Wrath endpoints restored',error=str(error),cutover=False)
+ raise
+
+if __name__=='__main__':
+ p=argparse.ArgumentParser();p.add_argument('--root',type=pathlib.Path,required=True);main(p.parse_args().root.resolve())
diff --git a/deploy/classic/health.py b/deploy/classic/health.py
new file mode 100644
index 0000000..29a28b0
--- /dev/null
+++ b/deploy/classic/health.py
@@ -0,0 +1,19 @@
+#!/usr/bin/env python3
+"""Classic host readings only; never runs Wrath maintenance SQL."""
+import argparse,json,pathlib,shutil,subprocess,time,urllib.request
+p=argparse.ArgumentParser();p.add_argument('--root',type=pathlib.Path,required=True);p.add_argument('--url',default='http://127.0.0.1:8787');a=p.parse_args()
+v={}
+for line in (a.root/'app-staging/.dev.vars').read_text().splitlines():
+ if '=' in line:
+ k,value=line.split('=',1);v[k]=value.strip().strip('"\'')
+def cpu():
+ values=list(map(int,pathlib.Path('/proc/stat').read_text().splitlines()[0].split()[1:]));return sum(values[:8]),sum(values[3:5])
+x=cpu();time.sleep(1);y=cpu()
+m={l.split(':')[0]:int(l.split()[1])/1024 for l in pathlib.Path('/proc/meminfo').read_text().splitlines()}
+model=False
+try:
+ with urllib.request.urlopen(v['LLM_URL'].rstrip('/')+'/api/tags',timeout=5) as response:model=response.status==200
+except Exception:pass
+body=dict(host='ubuntu',at=time.time(),cpuPercent=max(0,min(100,100*(1-(y[1]-x[1])/max(1,y[0]-x[0])))),memoryTotalMB=m['MemTotal'],memoryAvailableMB=m['MemAvailable'],diskFreeGB=shutil.disk_usage(a.root).free/1024**3,modelAvailable=model,runtimeService=subprocess.check_output(['systemctl','--user','is-active','azerothai-classic-runtime'],text=True).strip(),maintenance='Classic telemetry active; Wrath maintenance disabled')
+req=urllib.request.Request(a.url+'/api/operations',data=json.dumps(body).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+v['AUTONOMY_TOKEN']})
+with urllib.request.urlopen(req,timeout=15) as response:assert response.status==200
diff --git a/deploy/classic/install-native.py b/deploy/classic/install-native.py
index 333fc82..ca15b56 100644
--- a/deploy/classic/install-native.py
+++ b/deploy/classic/install-native.py
@@ -37,6 +37,36 @@ def install(source):
anchor = 'void PlayerbotHolder::AddPlayerBot(uint32 playerGuid, uint32 masterAccount)\n{'
patch(path, anchor, anchor + '\n if (!AzerothAIAllowBotLogin(playerGuid)) return;')
+ # Hooks record successful native outcomes, not inferred command success.
+ player = source / "src/game/Entities/Player.cpp"
+ loot = source / "src/game/Loot/LootMgr.cpp"
+ for path in [player, loot]:
+ if include not in path.read_text():
+ path.write_text(include + path.read_text())
+ hooks = [
+ (player, " // resend quests status directly\n UpdateForQuestWorldObjects();", 'AzerothAIEvent(this,"player.quest.reward",quest_id);'),
+ (player, "void Player::CompleteQuest(uint32 quest_id)\n{\n if (quest_id)\n {\n SetQuestStatus(quest_id, QUEST_STATUS_COMPLETE);", 'AzerothAIEvent(this,"player.quest.complete",quest_id);'),
+ (player, " SetDeathState(CORPSE);", 'AzerothAIEvent(this,"player.death");'),
+ (loot, " plr->ModifyMoney(money_per_player);", 'AzerothAIEvent(plr,"player.loot.money",0,money_per_player);'),
+ (loot, " player->ModifyMoney(m_gold);", 'AzerothAIEvent(player,"player.loot.money",0,m_gold);'),
+ ]
+ for path, anchor, call in hooks:
+ if call:
+ patch(path, anchor, anchor + "\n#ifdef ENABLE_PLAYERBOTS\n " + call + "\n#endif")
+ for anchor, call in [
+ (" if (!applySickness)\n return;", 'AzerothAIEvent(this,"player.resurrect");'),
+ (" WorldPacket data(SMSG_LEARNED_SPELL, 4);", 'AzerothAIEvent(this,"player.spell.learn",spell_id);'),
+ (" WorldPacket data(SMSG_ITEM_PUSH_RESULT, (8 + 4 + 4 + 4 + 1 + 4 + 4 + 4 + 4 + 4));", 'if (!received && !created) AzerothAIEvent(this,"player.loot.item",item->GetEntry(),count);'),
+ ]:
+ patch(player, anchor, "#ifdef ENABLE_PLAYERBOTS\n " + call + "\n#endif\n" + anchor)
+
+ chat=source / "src/game/Chat/ChatHandler.cpp"
+ if include not in chat.read_text():chat.write_text(include+chat.read_text())
+ for anchor,call in [
+ (' GetPlayer()->Say(msg, lang);', 'if(lang!=LANG_ADDON && !m_anticheat->IsSilenced()) AzerothAIChat(GetPlayer(),msg);'),
+ (' if (lang != LANG_ADDON && !m_anticheat->IsSilenced())\n m_anticheat->Whisper(msg, player->GetObjectGuid());', 'if(lang!=LANG_ADDON && !m_anticheat->IsSilenced()) AzerothAIChat(GetPlayer(),msg,player);')
+ ]:patch(chat,anchor,anchor+'\n#ifdef ENABLE_PLAYERBOTS\n '+call+'\n#endif')
+
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("source", type=pathlib.Path)
diff --git a/deploy/classic/native/AzerothAI.cpp b/deploy/classic/native/AzerothAI.cpp
index b14bcbe..adf9191 100644
--- a/deploy/classic/native/AzerothAI.cpp
+++ b/deploy/classic/native/AzerothAI.cpp
@@ -9,6 +9,7 @@
#include "Config/Config.h"
#include "Database/DatabaseEnv.h"
#include "playerbot/PlayerbotAI.h"
+#include "playerbot/PlayerbotAIConfig.h"
#include "playerbot/RandomPlayerbotMgr.h"
#include
#include
@@ -62,8 +63,15 @@ void commands() {
Player* p=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,f[1].GetUInt32()));
Player* peer=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,f[6].GetUInt32()));
if (!managed(p) || p->GetSession()->GetAccountId()!=f[2].GetUInt32() || std::string(p->GetName())!=f[3].GetString() || std::time(nullptr)-f[7].GetUInt32()>90 || std::time(nullptr)>f[10].GetUInt32()) { receipt(id,false,"Stale command or managed identity unavailable"); continue; }
- if (f[6].GetUInt32() && (!managed(peer) || peer->GetSession()->GetAccountId()!=f[8].GetUInt32() || std::string(peer->GetName())!=f[9].GetString())) {receipt(id,false,"Peer identity changed or protected");continue;}
- if (kind=="party") {
+ bool humanReply=kind=="reply-say" || kind=="reply-whisper";
+ bool peerAllowed=humanReply ? peer && peer->IsInWorld() && (!peer->GetPlayerbotAI() || peer->GetPlayerbotAI()->IsRealPlayer()) : managed(peer);
+ if (f[6].GetUInt32() && (!peerAllowed || peer->GetSession()->GetAccountId()!=f[8].GetUInt32() || std::string(peer->GetName())!=f[9].GetString())) {receipt(id,false,"Peer identity changed or protected");continue;}
+ if (humanReply) {
+ if (arg.empty() || arg.size()>1000 || !peer || p->GetTeam()!=peer->GetTeam() || (kind=="reply-say" && (p->GetMapId()!=peer->GetMapId() || p->GetDistance(peer)>25))) {receipt(id,false,"Player conversation no longer available");continue;}
+ if(kind=="reply-whisper")p->Whisper(arg,LANG_UNIVERSAL,peer->GetObjectGuid());
+ else p->Say(arg,p->GetTeam()==ALLIANCE?LANG_COMMON:LANG_ORCISH);
+ receipt(id,true,"In-game player reply sent");
+ } else if (kind=="party") {
if (!nearby(p,peer,35) || !safeGroup(p) || !safeGroup(peer) || peer->GetGroup() || peer->GetGroupInvite() || p->GetGroupInvite() || (p->GetGroup() && (p->GetGroup()->GetMembersCount()>=5 || p->GetGroup()->GetLeaderGuid()!=p->GetObjectGuid()))) { receipt(id,false,"Party participants unavailable or protected"); continue; }
WorldPacket invite(CMSG_GROUP_INVITE,32); invite << std::string(peer->GetName()); p->GetSession()->HandleGroupInviteOpcode(invite);
if (peer->GetGroupInvite()) { WorldPacket accept(CMSG_GROUP_ACCEPT,0); peer->GetSession()->HandleGroupAcceptOpcode(accept); }
@@ -139,6 +147,47 @@ void AzerothAIUpdate(uint32_t diff) {
for(auto const& pair:roster){auto const&r=pair.second;Player* p=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,r.guid));
if(!p){if(std::time(nullptr)-loginAttempt[r.guid]>=60){auto identity=CharacterDatabase.PQuery("SELECT guid FROM characters WHERE guid=%u AND account=%u AND name='%s'",r.guid,r.account,sql(r.name).c_str());if(identity){sRandomPlayerbotMgr.AddPlayerBot(r.guid,0);loginAttempt[r.guid]=std::time(nullptr);}}continue;}
if(identified(p))snapshot(p);
+ // Honor the configured native auto-learn policy even for externally
+ // maintained bot sessions. Do not grant spells through a second path.
+ static std::map trainedLevel;
+ if(sPlayerbotAIConfig.autoLearnTrainerSpells && free(p) && trainedLevel[r.guid]!=p->GetLevel()){
+ if(p->GetPlayerbotAI()->DoSpecificAction("auto learn spell"))trainedLevel[r.guid]=p->GetLevel();
+ }
}
commands();
}
+
+void AzerothAIEvent(Player* p, const char* kind, uint32_t value, uint32_t amount) {
+ if (!sConfig.GetBoolDefault("AzerothAI.Enable",false) || !managed(p)) return;
+ std::string eventKind(kind), column;
+ if(eventKind=="player.quest.complete")column="quests";
+ else if(eventKind=="player.quest.reward")column="rewarded";
+ else if(eventKind=="player.loot.item")column="lootItems";
+ else if(eventKind=="player.loot.money")column="lootedCopper";
+ else if(eventKind=="player.death")column="nativeDeaths";
+ else if(eventKind=="player.spell.learn")column="learnedSpells";
+ else if(eventKind=="player.resurrect")column="resurrections";
+ if(!column.empty())CharacterDatabase.PExecute("INSERT INTO azerothai_counters(guid,%s) VALUES(%u,%u) ON DUPLICATE KEY UPDATE %s=%s+%u",column.c_str(),p->GetGUIDLow(),amount,column.c_str(),column.c_str(),amount);
+ if(eventKind=="player.loot.item")CharacterDatabase.PExecute("INSERT INTO azerothai_counters(guid,lootEvents) VALUES(%u,1) ON DUPLICATE KEY UPDATE lootEvents=lootEvents+1",p->GetGUIDLow());
+ std::ostringstream out;
+ out << "{\"isBot\":true,\"characterGuid\":" << p->GetGUIDLow() << ",\"accountId\":" << p->GetSession()->GetAccountId()
+ << ",\"name\":" << quoted(p->GetName()) << ",\"level\":" << p->GetLevel() << ",\"observedAt\":" << std::time(nullptr)
+ << ",\"value\":" << value << ",\"amount\":" << amount;
+ if(auto area=GetAreaEntryByAreaID(p->GetZoneId()))out << ",\"zoneName\":" << quoted(area->area_name[0]);
+ out << '}';
+ CharacterDatabase.PExecute("INSERT INTO azerothai_events(at,kind,payload) VALUES(UNIX_TIMESTAMP(),'%s','%s')",sql(eventKind).c_str(),sql(out.str()).c_str());
+}
+
+void AzerothAIChat(Player* p, const std::string& message, Player* receiver) {
+ if(!sConfig.GetBoolDefault("AzerothAI.Enable",false) || !p || !p->IsInWorld() || (p->GetPlayerbotAI() && !p->GetPlayerbotAI()->IsRealPlayer()) || message.empty() || message.size()>500 || message[0]=='.' || message[0]=='!')return;
+ if(receiver && !managed(receiver))return;
+ std::string names;
+ for(auto const& r:roster){Player* bot=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,r.first));if(managed(bot) && bot->GetTeam()==p->GetTeam() && bot->GetMapId()==p->GetMapId() && bot->GetDistance(p)<=25){if(!names.empty())names+=",";names+=bot->GetName();}}
+ if(!receiver && names.empty())return;
+ std::ostringstream out;
+ out << "{\"isBot\":false,\"characterGuid\":" << p->GetGUIDLow() << ",\"accountId\":" << p->GetSession()->GetAccountId() << ",\"name\":" << quoted(p->GetName())
+ << ",\"message\":" << quoted(message) << ",\"nearbyBots\":" << quoted(names) << ",\"receiver\":" << quoted(receiver?receiver->GetName():"") << ",\"receiverIsBot\":" << (receiver?"true":"false");
+ if(auto area=GetAreaEntryByAreaID(p->GetZoneId()))out << ",\"zoneName\":" << quoted(area->area_name[0]);
+ out << '}';
+ CharacterDatabase.PExecute("INSERT INTO azerothai_events(at,kind,payload) VALUES(UNIX_TIMESTAMP(),'%s','%s')",receiver?"player.whisper":"player.chat",sql(out.str()).c_str());
+}
diff --git a/deploy/classic/native/AzerothAI.h b/deploy/classic/native/AzerothAI.h
index 55a03a4..9a90094 100644
--- a/deploy/classic/native/AzerothAI.h
+++ b/deploy/classic/native/AzerothAI.h
@@ -4,3 +4,8 @@ class Unit;
void AzerothAIUpdate(uint32_t diff);
uint32_t AzerothAIBoundDamage(Unit* victim, uint32_t damage);
bool AzerothAIAllowBotLogin(uint32_t guid);
+class Player;
+void AzerothAIEvent(Player* player, const char* kind, uint32_t value = 0, uint32_t amount = 1);
+
+#include
+void AzerothAIChat(Player* player, const std::string& message, Player* receiver = nullptr);
diff --git a/deploy/classic/prepare-auth.py b/deploy/classic/prepare-auth.py
new file mode 100644
index 0000000..9d1488a
--- /dev/null
+++ b/deploy/classic/prepare-auth.py
@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+"""Prepare isolated Classic authentication, preserving ENDAMI's SRP credentials.
+Does not stop Wrath or alter any Wrath account. Secrets are never printed.
+"""
+import argparse,base64,hashlib,json,os,pathlib,re,secrets,subprocess
+
+def main(root):
+ credentials=json.loads((root/'database-credentials.json').read_text())
+ def query(container,password,sql):
+ result=subprocess.run(['docker','exec','-i','-e','MYSQL_PWD',container,'mysql','-uroot','-N','-B'],input=sql,text=True,capture_output=True,env=dict(os.environ,MYSQL_PWD=password))
+ if result.returncode:raise RuntimeError('Authentication database operation failed')
+ return result.stdout.strip()
+ def classic(sql):return query('azerothai-classic-db',credentials['root'],sql)
+ account_file=root/'classic-gm-credentials.json'
+ if account_file.exists():gm=json.loads(account_file.read_text())
+ else:
+ gm={'username':'AZCLASSICGM','password':secrets.token_hex(8)}
+ account_file.write_text(json.dumps(gm));account_file.chmod(0o600)
+ salt=secrets.token_bytes(32)
+ digest=hashlib.sha1((gm['username'].upper()+':'+gm['password'].upper()).encode()).digest()
+ exponent=int.from_bytes(hashlib.sha1(salt+digest).digest(),'little')
+ verifier=pow(7,exponent,int('894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7',16))
+ assert re.fullmatch('[A-Z0-9]+',gm['username'])
+ classic(f"INSERT INTO classicrealmd.account(username,s,v,gmlevel) VALUES ('{gm['username']}','{salt[::-1].hex()}','{verifier:X}',3) ON DUPLICATE KEY UPDATE gmlevel=3,s=VALUES(s),v=VALUES(v);")
+ # Disable upstream sample logins without deleting accounts or characters.
+ classic("UPDATE classicrealmd.account SET locked=1,lockedIp='disabled' WHERE id IN (1,2,3,4) AND username IN ('ADMINISTRATOR','GAMEMASTER','MODERATOR','PLAYER');")
+ config=json.loads(subprocess.check_output(['docker','inspect','ac-database']))[0]
+ env=dict(v.split('=',1) for v in config['Config']['Env'] if '=' in v)
+ password=env.get('MYSQL_ROOT_PASSWORD') or env.get('MARIADB_ROOT_PASSWORD')
+ row=query('ac-database',password,"SELECT a.username,HEX(a.salt),HEX(a.verifier),MAX(x.gmlevel) FROM acore_auth.account a JOIN acore_auth.account_access x ON x.id=a.id WHERE a.username='ENDAMI' GROUP BY a.id;").split('\t')
+ if len(row)!=4 or row[0]!='ENDAMI':raise RuntimeError('Expected existing human GM account was not found')
+ salt=bytes.fromhex(row[1])[::-1].hex();verifier=bytes.fromhex(row[2])[::-1].hex()
+ classic(f"INSERT INTO classicrealmd.account(username,s,v,gmlevel) VALUES ('ENDAMI','{salt}','{verifier}',3) ON DUPLICATE KEY UPDATE s=VALUES(s),v=VALUES(v),gmlevel=3;")
+ classic("UPDATE classicrealmd.realmlist SET name='AzerothAI Classic',address='10.0.0.125',port=8086,realmflags=0,allowedSecurityLevel=0 WHERE id=1;")
+ text=(root/'run/etc/realmd.conf.dist').read_text()
+ values={'LoginDatabaseInfo':'"azerothai-classic-db;3306;'+credentials['user']+';'+credentials['password']+';classicrealmd"','RealmServerPort':'3724','BindIP':'"0.0.0.0"','LogsDir':'"/work/run/logs"'}
+ for key,value in values.items():
+ pattern=r'^'+re.escape(key)+r'\s*=.*$';line=key+' = '+value
+ text=re.sub(pattern,lambda _:line,text,flags=re.M) if re.search(pattern,text,re.M) else text+'\n'+line+'\n'
+ path=root/'run/etc/realmd.conf';path.write_text(text);path.chmod(0o600)
+ print('Classic GM credentials prepared; ENDAMI password verifier preserved; sample logins disabled.')
+
+if __name__=='__main__':
+ parser=argparse.ArgumentParser();parser.add_argument('--root',type=pathlib.Path,required=True)
+ main(parser.parse_args().root.resolve())
diff --git a/deploy/classic/relay.py b/deploy/classic/relay.py
index ac0c9a5..efecd03 100644
--- a/deploy/classic/relay.py
+++ b/deploy/classic/relay.py
@@ -14,12 +14,12 @@ CLASSES={1:'Warrior',2:'Paladin',3:'Hunter',4:'Rogue',5:'Priest',7:'Shaman',8:'M
def validate(command):
if not isinstance(command,dict) or command.get('version')!=1 or command.get('backend')!='cmangos-classic': raise ValueError('Not a typed Classic command')
- if command.get('kind') not in {'party','leave','recruit','intent','say'}:raise ValueError('Unsupported command kind')
+ if command.get('kind') not in {'party','leave','recruit','intent','say','reply-say','reply-whisper'}:raise ValueError('Unsupported command kind')
for key in ['guid','accountId','peerGuid','peerAccountId','expiresAt']:
if type(command.get(key)) is not int or command[key]<0:raise ValueError('Invalid '+key)
if not command['guid'] or not command['accountId'] or not re.fullmatch('[A-Za-z]{2,12}',command.get('name','')):raise ValueError('Invalid actor identity')
if command['peerGuid'] and (not command['peerAccountId'] or not re.fullmatch('[A-Za-z]{2,12}',command.get('peerName',''))):raise ValueError('Invalid peer identity')
- if command['kind'] in {'party','recruit','say'} and not command['peerGuid']:raise ValueError('Peer identity required')
+ if command['kind'] in {'party','recruit','say','reply-say','reply-whisper'} and not command['peerGuid']:raise ValueError('Peer identity required')
if not isinstance(command.get('argument'),str) or len(command['argument'].encode())>1000:raise ValueError('Invalid argument')
if command['expiresAt']time.time()+120:raise ValueError('Expired or invalid deadline')
if command['kind']=='intent' and command['argument'] not in {'quest','explore','rest','trade','socialize'}:raise ValueError('Unsupported intention')
@@ -43,7 +43,7 @@ def main(root,base,env_file):
req=urllib.request.Request(base.rstrip('/')+path,data=body,headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'})
with urllib.request.urlopen(req,timeout=10) as response:return response.headers,response.read().decode()
def complete(id,ok,result):request('/api/bridge/ale/result',{'id':id,'ok':ok,'result':result})
- last_snapshot=0;last_cleanup=0
+ last_snapshot=0;last_cleanup=0;last_outcomes=0
while True:
try:
now=int(time.time())
@@ -68,6 +68,19 @@ def main(root,base,env_file):
if pairs:
a,b=pairs[(now//10)%len(pairs)];roster.update(socialA=a['name'],socialB=b['name'],zoneName=a.get('zoneName',''))
request('/api/bridge/ale/snapshots',{'payloads':payloads+[roster]});last_snapshot=now
+ for event_id,kind,payload in mysql("SELECT id,kind,HEX(payload) FROM azerothai_events WHERE delivered=0 ORDER BY id LIMIT 30"):
+ data=json.loads(bytes.fromhex(payload));data['nativeEventId']='classic:'+event_id
+ request('/api/bridge/ale/event',{'kind':kind,'payload':data})
+ mysql('UPDATE azerothai_events SET delivered=1 WHERE id='+str(int(event_id)))
+ if now-last_outcomes>=60:
+ rows=mysql("SELECT r.guid,r.name,COALESCE(c.quests,0),COALESCE(c.rewarded,0),COALESCE(c.lootEvents,0),COALESCE(c.lootItems,0),COALESCE(c.lootedCopper,0),COALESCE(c.nativeDeaths,0) FROM azerothai_roster r LEFT JOIN azerothai_counters c ON c.guid=r.guid WHERE r.enabled=1")
+ outcomes=[]
+ for guid,name,quests,rewarded,events,items,copper,deaths in rows:
+ outcomes.append(dict(guid=int(guid),name=name,quests=int(quests),rewarded=int(rewarded),lootEvents=int(events),lootItems=int(items),lootedCopper=int(copper),nativeDeaths=int(deaths),abandoned=0,partyKills=0))
+ body=json.dumps(dict(at=now,recoveries=[],outcomes=outcomes)).encode()
+ req=urllib.request.Request(base.rstrip('/')+'/api/realm-insights',data=body,headers={'Authorization':'Bearer '+values['AUTONOMY_TOKEN'],'Content-Type':'application/json'})
+ with urllib.request.urlopen(req,timeout=10) as response:response.read()
+ last_outcomes=now
headers,body=request('/api/bridge/ale/poll')
id=headers.get('X-AzerothAI-Command')
if id:
@@ -80,6 +93,7 @@ def main(root,base,env_file):
complete(id,status=='completed',bytes.fromhex(result).decode());mysql('UPDATE azerothai_commands SET delivered=1 WHERE id='+quote(id))
mysql("UPDATE azerothai_commands SET status='failed',result='Expired before native execution' WHERE status='pending' AND expires_at3600:
+ mysql("DELETE FROM azerothai_events WHERE delivered=1 AND at
assert.equal(command.peerGuid,8);assert.equal(command.peerAccountId,4);
assert.ok(command.expiresAt>Date.now()/1000);
});
+
+test('Classic human replies require authoritative recipient identity',()=>{
+ const {classicPlayerReply}=require('../.test-build/classic.js');
+ for(const request of [{speakerName:'Endami',channel:'whisper'},{speakerName:'Endami',channel:'say',speakerGuid:-1,speakerAccountId:1}])assert.throws(()=>classicPlayerReply(actor,request,'Hello'));
+ const reply=JSON.parse(classicPlayerReply(actor,{speakerName:'Endami',channel:'whisper',speakerGuid:51,speakerAccountId:11},'Hello'));
+ assert.equal(reply.kind,'reply-whisper');assert.equal(reply.peerGuid,51);assert.equal(reply.peerAccountId,11);assert.equal(reply.peerName,'Endami');
+ assert.equal(reply.guid,7);assert.equal(reply.argument,'Hello');
+});