From 6f9cdcb5a4b6619c14ad083376091f17083ec1a4 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 25 Sep 2026 18:19:50 -0700 Subject: [PATCH] Complete Classic telemetry, authenticated player replies and reversible cutover --- app/api/ale/operator/route.ts | 6 ++ app/api/bridge/ale/event/route.ts | 10 +++ app/page.tsx | 10 +-- core/classic.ts | 6 ++ core/conversation-store.ts | 8 +++ core/conversation.ts | 5 +- core/gm.ts | 5 +- core/service.ts | 7 ++- deploy/classic/README.md | 14 +++++ deploy/classic/check-auth.py | 43 +++++++++++++ deploy/classic/cutover.py | 98 +++++++++++++++++++++++++++++ deploy/classic/health.py | 19 ++++++ deploy/classic/install-native.py | 30 +++++++++ deploy/classic/native/AzerothAI.cpp | 53 +++++++++++++++- deploy/classic/native/AzerothAI.h | 5 ++ deploy/classic/prepare-auth.py | 45 +++++++++++++ deploy/classic/relay.py | 20 +++++- deploy/classic/schema.sql | 19 ++++++ tests/classic.test.cjs | 8 +++ 19 files changed, 394 insertions(+), 17 deletions(-) create mode 100644 deploy/classic/check-auth.py create mode 100644 deploy/classic/cutover.py create mode 100644 deploy/classic/health.py create mode 100644 deploy/classic/prepare-auth.py diff --git a/app/api/ale/operator/route.ts b/app/api/ale/operator/route.ts index a2a1301..3da4080 100644 --- a/app/api/ale/operator/route.ts +++ b/app/api/ale/operator/route.ts @@ -1,6 +1,7 @@ import { env } from 'cloudflare:workers'; import { z } from 'zod'; import { requireAleOperator,rawAleEnabled } from '@/core/ale-auth'; +import {runGM} from '@/core/gm'; import { compileAleOperatorRequest } from '@/core/ale'; import { queueAleCommand } from '@/core/ale-store'; import type { RuntimeConfig } from '@/core/types'; @@ -20,6 +21,11 @@ export async function POST(request:Request){ requireAleOperator(request,config); const text=await request.text();if(text.length>16000)return Response.json({error:'Request too large'},{status:413}); const body=schema.parse(JSON.parse(text)); + if(config.GAME_BACKEND==='cmangos-classic'){ + if(body.kind!=='command')throw new Error('Classic supports native GM commands here; Lua/ALE methods belong to the Wrath backend.'); + const result=await runGM(body.command,true,true,config); + return Response.json({ok:true,result}); + } const source=compileAleOperatorRequest(body,rawAleEnabled(config)); const queued=await queueAleCommand(env.DB!,source); return Response.json({ok:true,command:queued},{status:202}); diff --git a/app/api/bridge/ale/event/route.ts b/app/api/bridge/ale/event/route.ts index 48847ca..4b0da25 100644 --- a/app/api/bridge/ale/event/route.ts +++ b/app/api/bridge/ale/event/route.ts @@ -26,6 +26,7 @@ const STATEFUL_EVENTS=new Set([ 'player.close-call', 'player.killed-by-creature', 'player.permanent-death', + 'player.death', 'player.repop', 'player.resurrect' ]); @@ -36,6 +37,11 @@ export async function POST(request:Request){ requireAleBridge(request,config); const text=await request.text();if(text.length>40000)return Response.json({error:'Event too large'},{status:413}); const body=schema.parse(JSON.parse(text)); + const eventId=typeof body.payload.nativeEventId==='string'?body.payload.nativeEventId.slice(0,100):null; + if(eventId){ + await env.DB!.prepare('CREATE TABLE IF NOT EXISTS native_event_receipts (id TEXT PRIMARY KEY,at INTEGER)').run(); + if(await env.DB!.prepare('SELECT id FROM native_event_receipts WHERE id=?').bind(eventId).first())return Response.json({ok:true,duplicate:true}); + } await recordAleEvent(env.DB!,body.kind,body.payload); if((body.kind==='player.chat'||body.kind==='player.whisper')&&body.payload.isBot!==true){ @@ -54,6 +60,10 @@ export async function POST(request:Request){ if(zone)await execute(store,{type:'worldTrigger',kind:'zone-change',zone,playerName:String(body.payload.name??'').slice(0,40),detail:'Authoritative ALE human-player zone event'},config); } + if(eventId){ + await env.DB!.prepare('INSERT OR IGNORE INTO native_event_receipts VALUES (?,?)').bind(eventId,Math.floor(Date.now()/1000)).run(); + await env.DB!.prepare('DELETE FROM native_event_receipts WHERE atb.id===botId); const visible=state?.bots.filter(b=>(`${b.name} ${b.role} ${b.zone}`.toLowerCase().includes(query.toLowerCase()))&&(filter==='all'||b.status===filter))??[]; function botTable(bots:Bot[]){return CharacterStatusLocationRecipe{bots.map(b=>setBotId(b.id)}>
Lv. {b.level} · {b.race} {b.role}
{b.status}{b.zone}{b.personality.name.replace('The ','')}
)}
} - return
A
AzerothAIYOUR WORLD. ALIVE.
REALM MANAGEMENT
{NAV.map(([name,Icon])=>nav(name)}>{name})}

{readOnly?'Realm visitor · Read only':'Realm administrator'}

{readOnly?'Live realm monitor':'Prototype 0.1 · Private realm'}

“What if Azeroth never felt empty?”

Living Realm / {page}
{capabilities.aleActiveRecently?'AZEROTHCORE LIVE':state?.settings.connector==='azerothcore'?'AzerothCore bridge':'SIMULATION REALM'}{readOnly?'Visitor · Read only':'GM workspace'}
+ return
A
AzerothAIYOUR WORLD. ALIVE.
REALM MANAGEMENT
{NAV.map(([name,Icon])=>nav(name)}>{name})}

{readOnly?'Realm visitor · Read only':'Realm administrator'}

{readOnly?'Live realm monitor':'Prototype 0.1 · Private realm'}

“What if Azeroth never felt empty?”

Living Realm / {page}
{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]}

{readOnly?:page==='Bot Recipes'?:<>}
{error&&
{error}
} {!state?
{[1,2,3,4].map(n=>)}
:<> @@ -60,7 +60,7 @@ export default function Home({readOnly=false}:{readOnly?:boolean}){ {page==='Server & Models'&&
} {page==='GM Console'&&
} } -

AzerothAI shapes identity and intent. AzerothCore owns the rules of the world.

+

AzerothAI shapes identity and intent. The game server owns the rules of the world.

Create a characterStart 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 profileIdentity, intent, and the story so far.{selected&&state&&} @@ -89,7 +89,7 @@ function WorldMonitor({state,monitor,capabilities,openBot}:any){ const lastAle=monitor?.ale?.lastEventAt?new Date(monitor.ale.lastEventAt):null; const aleFresh=!!monitor?.ale?.activeRecently; const systemRows=[ - ['AzerothCore',aleFresh?'Live':(capabilities.aleConfigured?'Waiting for telemetry':'Not configured'),aleFresh], + ['Game server',aleFresh?'Live':(capabilities.aleConfigured?'Waiting for telemetry':'Not configured'),aleFresh], ['ALE',capabilities.aleConfigured?(aleFresh?'Live':'Configured'):'Not configured',capabilities.aleConfigured&&aleFresh], ['Playerbots',managed.length>0?`${managed.length} persistent lives bound`:'Awaiting live bot telemetry',managed.length>0], ['AI brain',state.settings.provider==='deterministic'?'Deterministic':(capabilities.llmConfigured?state.settings.brains.heroModel:'Endpoint missing'),state.settings.provider==='deterministic'||capabilities.llmConfigured], @@ -144,9 +144,9 @@ function Settings({state,act,busy,capabilities}:any){ const brains=s.brains; const setBrains=(patch:any)=>setS({...s,brains:{...brains,...patch}}); return
-
setS({...s,connector})} items={[{value:'simulation',label:'Simulation · Ready to use'},{value:'azerothcore',label:'AzerothCore · HTTP bridge'}]}/>
Game bridge configured{capabilities.bridgeConfigured?'Yes':'Not yet'}
ALE control plane{capabilities.aleConfigured?'Configured':'Not yet'}
GM SOAP configured{capabilities.soapConfigured?'Yes':'Not yet'}
Mechanics authorityAzerothCore
{health&&

{health}

}
+
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'}
GM SOAP configured{capabilities.soapConfigured?'Yes':'Not yet'}
Mechanics authorityNative game server
{health&&

{health}

}
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.

AzerothAI

Identity → memory retrieval → brain routing → high-level intent

AzerothCore + ALE

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?'LIVE COMMANDS':'SIMULATED COMMANDS'}}>
<GM> AZEROTHAI
Realm operations terminal · Command channel ready

{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.

}
{e.preventDefault();if(!command.trim())return;setHistory(h=>[command,...h].slice(0,30));setIndex(-1);if(live){setConfirm({title:'Execute on the live worldserver?',description:command,action:{type:'gm',command,live:true,confirmed:true}});return;}try{await act({type:'gm',command,live:false});setCommand('');}catch{}}}>>setCommand(e.target.value)} onKeyDown={e=>{if(e.key==='ArrowUp'){e.preventDefault();const n=Math.min(index+1,history.length-1);setIndex(n);if(n>=0)setCommand(history[n]);}if(e.key==='ArrowDown'){e.preventDefault();const n=Math.max(-1,index-1);setIndex(n);setCommand(n>=0?history[n]:'');}}}/>
{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?'LIVE COMMANDS':'SIMULATED COMMANDS'}}>
<GM> AZEROTHAI
Realm operations terminal · Command channel ready

{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.

}
{e.preventDefault();if(!command.trim())return;setHistory(h=>[command,...h].slice(0,30));setIndex(-1);if(live){setConfirm({title:'Execute on the live worldserver?',description:command,action:{type:'gm',command,live:true,confirmed:true}});return;}try{await act({type:'gm',command,live:false});setCommand('');}catch{}}}>>setCommand(e.target.value)} onKeyDown={e=>{if(e.key==='ArrowUp'){e.preventDefault();const n=Math.min(index+1,history.length-1);setIndex(n);if(n>=0)setCommand(history[n]);}if(e.key==='ArrowDown'){e.preventDefault();const n=Math.max(-1,index-1);setIndex(n);setCommand(n>=0?history[n]:'');}}}/>
{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'); +});