Author SHA1 Message Date
Codex f91724ebfa Route dashboard settings and decisions through the Classic connector
Core tests / core (push) Successful in 27s
2026-09-25 18:32:31 -07:00
Codex 3019acb9c1 Keep Classic service GM account outside managed bot account prefix
Core tests / core (push) Successful in 27s
2026-09-25 18:26:11 -07:00
Codex 0d0edc6599 Handle Miniflare metadata during Classic cutover and preserve daily backups
Core tests / core (push) Successful in 28s
2026-09-25 18:23:08 -07:00
Codex 6f9cdcb5a4 Complete Classic telemetry, authenticated player replies and reversible cutover
Core tests / core (push) Successful in 27s
2026-09-25 18:19:50 -07:00
Codex d7d4e899f1 Fix Classic bot login, native commands and unattended activity
Core tests / core (push) Successful in 26s
2026-09-25 17:26:54 -07:00
Codex f5099cd338 Allow first-start cache initialization and restore durability before roster creation
Core tests / core (push) Successful in 30s
2026-09-25 17:04:15 -07:00
Codex 02d21abcbd Stage guarded Classic migration and native bot adapter
Core tests / core (push) Successful in 29s
2026-09-25 22:57:11 +02:00
Codex 58b2019015 Add isolated Classic data staging with integrity checks 2026-09-25 22:05:17 +02:00
33 changed files with 1249 additions and 24 deletions
+6
View File
@@ -1,6 +1,7 @@
import { env } from 'cloudflare:workers'; import { env } from 'cloudflare:workers';
import { z } from 'zod'; import { z } from 'zod';
import { requireAleOperator,rawAleEnabled } from '@/core/ale-auth'; import { requireAleOperator,rawAleEnabled } from '@/core/ale-auth';
import {runGM} from '@/core/gm';
import { compileAleOperatorRequest } from '@/core/ale'; import { compileAleOperatorRequest } from '@/core/ale';
import { queueAleCommand } from '@/core/ale-store'; import { queueAleCommand } from '@/core/ale-store';
import type { RuntimeConfig } from '@/core/types'; import type { RuntimeConfig } from '@/core/types';
@@ -20,6 +21,11 @@ export async function POST(request:Request){
requireAleOperator(request,config); requireAleOperator(request,config);
const text=await request.text();if(text.length>16000)return Response.json({error:'Request too large'},{status:413}); 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)); 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 source=compileAleOperatorRequest(body,rawAleEnabled(config));
const queued=await queueAleCommand(env.DB!,source); const queued=await queueAleCommand(env.DB!,source);
return Response.json({ok:true,command:queued},{status:202}); return Response.json({ok:true,command:queued},{status:202});
+10
View File
@@ -26,6 +26,7 @@ const STATEFUL_EVENTS=new Set([
'player.close-call', 'player.close-call',
'player.killed-by-creature', 'player.killed-by-creature',
'player.permanent-death', 'player.permanent-death',
'player.death',
'player.repop', 'player.repop',
'player.resurrect' 'player.resurrect'
]); ]);
@@ -36,6 +37,11 @@ export async function POST(request:Request){
requireAleBridge(request,config); requireAleBridge(request,config);
const text=await request.text();if(text.length>40000)return Response.json({error:'Event too large'},{status:413}); 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 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); await recordAleEvent(env.DB!,body.kind,body.payload);
if((body.kind==='player.chat'||body.kind==='player.whisper')&&body.payload.isBot!==true){ 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(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 at<?').bind(Math.floor(Date.now()/1000)-8*86400).run();
}
return Response.json({ok:true}); return Response.json({ok:true});
}catch(e){ }catch(e){
const message=e instanceof Error?e.message:'ALE event failure'; const message=e instanceof Error?e.message:'ALE event failure';
+3 -2
View File
@@ -1,3 +1,4 @@
import {classicMode} from '@/core/classic';
import { env } from 'cloudflare:workers'; import { env } from 'cloudflare:workers';
import { requireAleBridge } from '@/core/ale-auth'; import { requireAleBridge } from '@/core/ale-auth';
import { pollAleCommand } from '@/core/ale-store'; import { pollAleCommand } from '@/core/ale-store';
@@ -9,8 +10,8 @@ async function handlePoll(request:Request){
try{ try{
requireAleBridge(request,config); requireAleBridge(request,config);
const command=await pollAleCommand(env.DB!); const command=await pollAleCommand(env.DB!);
if(!command)return new Response('return',{status:200,headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store'}}); if(!command)return new Response(classicMode(config)?'null':'return',{status:200,headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store'}});
return new Response(wrapAleCommand(command.id,command.source),{status:200,headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store','X-AzerothAI-Command':command.id}}); return new Response(classicMode(config)?command.source:wrapAleCommand(command.id,command.source),{status:200,headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store','X-AzerothAI-Command':command.id}});
}catch(e){ }catch(e){
const message=e instanceof Error?e.message:'ALE bridge failure'; const message=e instanceof Error?e.message:'ALE bridge failure';
return Response.json({error:message},{status:message.startsWith('Unauthorized')?401:500}); return Response.json({error:message},{status:message.startsWith('Unauthorized')?401:500});
+2 -1
View File
@@ -15,7 +15,8 @@ export async function GET(){
ok:true, ok:true,
aleActiveRecently, aleActiveRecently,
lastAleAt, 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, aleConfigured:!!c.ALE_BRIDGE_TOKEN,
aleOperatorConfigured:!!c.ALE_OPERATOR_TOKEN, aleOperatorConfigured:!!c.ALE_OPERATOR_TOKEN,
aleRawEnabled:rawAleEnabled(c), aleRawEnabled:rawAleEnabled(c),
+7 -7
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -1,3 +1,4 @@
import {classicMode,classicCommand} from './classic';
import {startIntentCheck} from './intent-evidence'; import {startIntentCheck} from './intent-evidence';
import {intentCompanions,partyIntentBusy,recordPartyIntent,compileCommunityAction} from './community'; import {intentCompanions,partyIntentBusy,recordPartyIntent,compileCommunityAction} from './community';
import {DeterministicProvider,providerForBot} from './llm'; import {DeterministicProvider,providerForBot} from './llm';
@@ -188,7 +189,7 @@ export async function runLiveAutonomyTick(
// Recheck eligibility after inference; native guards recheck once more at execution. // Recheck eligibility after inference; native guards recheck once more at execution.
let peer=decision.action==='socialize'?intentCompanions(s,bot)[0]:undefined; let peer=decision.action==='socialize'?intentCompanions(s,bot)[0]:undefined;
if(peer&&await partyIntentBusy(db,bot,peer))peer=undefined; if(peer&&await partyIntentBusy(db,bot,peer))peer=undefined;
const source=peer?compileCommunityAction('party',bot,peer):compileLivePlayerbotIntent(bot.name,decision); const source=classicMode(config)?(peer?classicCommand('party',bot,'',peer):classicCommand('intent',bot,decision.action)):(peer?compileCommunityAction('party',bot,peer):compileLivePlayerbotIntent(bot.name,decision));
const queued=await queueAleCommand(db,source); const queued=await queueAleCommand(db,source);
if(peer)await recordPartyIntent(db,s,queued.id,bot,peer); if(peer)await recordPartyIntent(db,s,queued.id,bot,peer);
const check=startIntentCheck(bot,decision,queued.id,Date.now()/1000,peer?.name); const check=startIntentCheck(bot,decision,queued.id,Date.now()/1000,peer?.name);
+17
View File
@@ -0,0 +1,17 @@
import type {Bot,RuntimeConfig} from './types';
export const classicMode=(config:RuntimeConfig)=>config.GAME_BACKEND==='cmangos-classic';
/** Typed native actions only; the Classic bridge never evaluates Lua. */
export function classicCommand(kind:'party'|'leave'|'recruit'|'intent'|'say',bot:Bot,argument='',peer?:Bot){
const identity=bot.autonomy;
if(!identity?.managed||!Number.isInteger(identity.characterGuid)||!identity.characterGuid||identity.characterGuid<1||!Number.isInteger(identity.accountId)||!identity.accountId||identity.accountId<1)throw new Error('Verified Classic character identity required');
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});
}
+4 -3
View File
@@ -1,5 +1,6 @@
import {classicMode,classicCommand} from './classic';
import {observeIntentChecks} from './intent-evidence'; import {observeIntentChecks} from './intent-evidence';
import type {Bot,State,StateStore,SocialEntry} from './types'; import type {Bot,State,StateStore,SocialEntry,RuntimeConfig} from './types';
import {luaString} from './ale'; import {luaString} from './ale';
import {queueAleCommand} from './ale-store'; import {queueAleCommand} from './ale-store';
@@ -103,7 +104,7 @@ async function ensure(db:D1Database){
await db.prepare('CREATE TABLE IF NOT EXISTS community_actions (id TEXT PRIMARY KEY,created_at TEXT,kind TEXT,a TEXT,b TEXT,command_id TEXT,status TEXT,reason TEXT)').run(); await db.prepare('CREATE TABLE IF NOT EXISTS community_actions (id TEXT PRIMARY KEY,created_at TEXT,kind TEXT,a TEXT,b TEXT,command_id TEXT,status TEXT,reason TEXT)').run();
await db.prepare('CREATE TABLE IF NOT EXISTS community_clock (id TEXT PRIMARY KEY,at INTEGER)').run(); await db.prepare('CREATE TABLE IF NOT EXISTS community_clock (id TEXT PRIMARY KEY,at INTEGER)').run();
} }
export async function communityTick(store:StateStore,db:D1Database){ export async function communityTick(store:StateStore,db:D1Database,config:RuntimeConfig={}){
await ensure(db); await ensure(db);
const pending=await db.prepare("SELECT * FROM community_actions WHERE status='pending' LIMIT 20").all<any>(); const pending=await db.prepare("SELECT * FROM community_actions WHERE status='pending' LIMIT 20").all<any>();
for(const action of pending.results){ for(const action of pending.results){
@@ -147,7 +148,7 @@ export async function communityTick(store:StateStore,db:D1Database){
} }
} }
async function propose(kind:'party'|'leave'|'recruit',a:Bot,b:Bot|undefined,reason:string){ async function propose(kind:'party'|'leave'|'recruit',a:Bot,b:Bot|undefined,reason:string){
const command=await queueAleCommand(db,compileCommunityAction(kind,a,b)); const command=await queueAleCommand(db,classicMode(config)?classicCommand(kind,a,kind==='leave'?a.world!.groupId:'',b):compileCommunityAction(kind,a,b));
await db.prepare('INSERT INTO community_actions VALUES (?,?,?,?,?,?,?,?)').bind(command.id,new Date().toISOString(),kind,a.id,b?.id??'',command.id,'pending',reason).run(); await db.prepare('INSERT INTO community_actions VALUES (?,?,?,?,?,?,?,?)').bind(command.id,new Date().toISOString(),kind,a.id,b?.id??'',command.id,'pending',reason).run();
await changeCommunity(store,s=>socialEntry(s,{id:command.id+':proposal',at:new Date().toISOString(),kind:'invitation',names:[a.name,...(b?[b.name]:[])],text:reason+' Awaiting game confirmation.',status:'pending'})); await changeCommunity(store,s=>socialEntry(s,{id:command.id+':proposal',at:new Date().toISOString(),kind:'invitation',names:[a.name,...(b?[b.name]:[])],text:reason+' Awaiting game confirmation.',status:'pending'}));
} }
+20 -1
View File
@@ -1,4 +1,7 @@
import { z } from 'zod'; 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'; import type { GameConnector,Bot,Decision,RuntimeConfig } from '../types';
const deathSignal=z.object({ 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}));} 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. // 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=>new AzerothCoreConnector(c)}; export const connectors:Record<string,(config:RuntimeConfig)=>GameConnector>={simulation:()=>new SimulationConnector(),azerothcore:c=>c.GAME_BACKEND==='cmangos-classic'?new ClassicConnector(c):new AzerothCoreConnector(c)};
+8
View File
@@ -1,6 +1,8 @@
export type ConversationChannel='say'|'whisper'; export type ConversationChannel='say'|'whisper';
export type ConversationRequest={ export type ConversationRequest={
speakerGuid?:number;
speakerAccountId?:number;
socialTurn?:0|1|2|3; socialTurn?:0|1|2|3;
id:string; id:string;
createdAt:string; createdAt:string;
@@ -16,6 +18,7 @@ export type ConversationRequest={
}; };
async function ensure(db:D1Database){ 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 ( await db.prepare(`CREATE TABLE IF NOT EXISTS conversation_requests (
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
created_at TEXT 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,'selectedPlayer',40),
text(payload,'nearbyBots',1000) text(payload,'nearbyBots',1000)
).run(); ).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}; return {id,createdAt};
} }
@@ -82,6 +88,7 @@ export async function leaseConversation(db:D1Database):Promise<ConversationReque
const row=await db.prepare(`SELECT id,created_at,speaker_name,message,channel,zone_name,receiver,selected_player,nearby_bots,status,attempts const row=await db.prepare(`SELECT id,created_at,speaker_name,message,channel,zone_name,receiver,selected_player,nearby_bots,status,attempts
FROM conversation_requests WHERE status='pending' ORDER BY CASE WHEN channel LIKE 'social-%' THEN 1 ELSE 0 END,created_at LIMIT 1`).first<any>(); FROM conversation_requests WHERE status='pending' ORDER BY CASE WHEN channel LIKE 'social-%' THEN 1 ELSE 0 END,created_at LIMIT 1`).first<any>();
if(!row)return null; 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 startedAt=new Date().toISOString();
const updated=await db.prepare(`UPDATE conversation_requests const updated=await db.prepare(`UPDATE conversation_requests
@@ -90,6 +97,7 @@ export async function leaseConversation(db:D1Database):Promise<ConversationReque
if(updated.meta.changes!==1)return null; if(updated.meta.changes!==1)return null;
return { return {
speakerGuid:identity?.guid,speakerAccountId:identity?.account_id,
socialTurn:row.channel==='social-start'?0:row.channel==='social-reply'?1:row.channel==='social-turn-2'?2:row.channel==='social-turn-3'?3:undefined, socialTurn:row.channel==='social-start'?0:row.channel==='social-reply'?1:row.channel==='social-turn-2'?2:row.channel==='social-turn-3'?3:undefined,
id:row.id, id:row.id,
createdAt:row.created_at, createdAt:row.created_at,
+4 -2
View File
@@ -1,3 +1,4 @@
import {classicMode,classicCommand,classicPlayerReply} from './classic';
import {bondEvidence,socialEntry,communityTick} from './community'; import {bondEvidence,socialEntry,communityTick} from './community';
import { z } from 'zod'; import { z } from 'zod';
import { queueAleCommand } from './ale-store'; import { queueAleCommand } from './ale-store';
@@ -180,7 +181,7 @@ export async function runConversationTick(store:StateStore,db:D1Database,config:
} }
} }
await cleanupConversations(db); await cleanupConversations(db);
await communityTick(store,db); await communityTick(store,db,config);
const request=await leaseConversation(db); const request=await leaseConversation(db);
if(!request)return {ok:true,ran:false,reason:'No player conversation waiting'}; if(!request)return {ok:true,ran:false,reason:'No player conversation waiting'};
@@ -202,7 +203,8 @@ export async function runConversationTick(store:StateStore,db:D1Database,config:
reply=cleanReply(reply); reply=cleanReply(reply);
if(!reply)throw new Error('Conversation reply was empty'); if(!reply)throw new Error('Conversation reply was empty');
const command=await queueAleCommand(db,compileReply(bot.name,request.speakerName,reply,request.channel,request.socialTurn!==undefined)); const source=classicMode(config)?(request.socialTurn===undefined?classicPlayerReply(bot,request,reply):classicCommand('say',bot,reply,byName(state,request.speakerName))):compileReply(bot.name,request.speakerName,reply,request.channel,request.socialTurn!==undefined);
const command=await queueAleCommand(db,source);
await awaitDelivery(db,request,bot.id,reply,command.id); await awaitDelivery(db,request,bot.id,reply,command.id);
await cleanupConversations(db); await cleanupConversations(db);
+3 -2
View File
@@ -7,11 +7,12 @@ export function parseGM(raw:string){
} }
const escapeXML=(s:string)=>s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&apos;'); const escapeXML=(s:string)=>s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&apos;');
export async function runGM(raw:string,live:boolean,confirmed:boolean,config:RuntimeConfig){ 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(!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(!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'); 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:`<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="urn:AC"><SOAP-ENV:Body><ns1:executeCommand><command>${escapeXML(command)}</command></ns1:executeCommand></SOAP-ENV:Body></SOAP-ENV:Envelope>`,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:`<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="${config.GAME_BACKEND==='cmangos-classic'?'urn:MaNGOS':'urn:AC'}"><SOAP-ENV:Body><ns1:executeCommand><command>${escapeXML(command)}</command></ns1:executeCommand></SOAP-ENV:Body></SOAP-ENV:Envelope>`,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 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]; 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'); if(output===undefined)throw new Error('Unexpected SOAP response; check the worldserver before retrying');
+6 -3
View File
@@ -210,7 +210,7 @@ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,pa
b.memories=b.memories.slice(-100); 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); 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){ 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); 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){ }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={}) { export async function execute(store:StateStore,input:unknown,config:RuntimeConfig={}) {
const a=actionSchema.parse(input);let s=await store.load(); const a=actionSchema.parse(input);let s=await store.load();
const connector=connectors[s.settings.connector](config); 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); 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(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.');} if(Object.keys(s.operations).length){s.operations={};event(s,'error','An interrupted operation expired. Verify live server state before retrying.');}
@@ -293,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.`); 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'){ }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!==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.'); 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}; 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'}.`); event(s,'system',`Realm connector and model settings updated. Hardcore one-life mode is ${a.hardcore.enabled?'enabled':'disabled'}.`);
+1 -1
View File
@@ -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<Observation>; lifecycle(bot:Bot,action:'spawn'|'pause'|'resume'|'despawn'|'retire',requestId:string):Promise<void>; decide(bot:Bot,decision:Decision,requestId:string):Promise<{summary:string;zone?:string}>;} export interface GameConnector {id:string; health():Promise<{ok:boolean; detail:string}>; observe(bot:Bot):Promise<Observation>; lifecycle(bot:Bot,action:'spawn'|'pause'|'resume'|'despawn'|'retire',requestId:string):Promise<void>; decide(bot:Bot,decision:Decision,requestId:string):Promise<{summary:string;zone?:string}>;}
export interface LLMProvider {id:string; decide(bot:Bot,observation:Observation):Promise<Decision>} export interface LLMProvider {id:string; decide(bot:Bot,observation:Observation):Promise<Decision>}
export interface StateStore {load():Promise<State>; save(state:State,expectedRevision:number):Promise<void>} export interface StateStore {load():Promise<State>; save(state:State,expectedRevision:number):Promise<void>}
export type RuntimeConfig = {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};
+100
View File
@@ -0,0 +1,100 @@
# Classic migration staging
Target: CMaNGOS Classic, client 1.12.1 build 5875, with CMaNGOS Playerbots.
This directory contains the staged adapter and migration helpers. A successful
build is not a completed migration or permission to bypass gameplay validation.
`stage-data.py --root /path/to/isolated/migration` observes an existing
`azerothai-classic-build` container, verifies the transferred MPQs against
`client-manifest.json`, and runs the upstream extractor with two worker threads.
The manifest maps relative MPQ paths to `size` and `sha256`. Checksums verify
transfer integrity against the supplied client manifest, not publisher authenticity.
The Docker builder image must be named `azerothai-classic-builder:local`; the
installed extractor must be under `run/bin/tools`. The staging directory and
container files belong to UID/GID 1000. Containers have a two-core/four-GiB limit.
Progress and failures are written to `prerequisites-status.json`. A successful
extraction is only a prerequisite: database import, the Classic control adapter,
the 50-character roster, account protection, and gameplay validation remain.
The script never starts the new realm, changes the old realm, or deletes bots.
`install-native.py SOURCE` installs the native adapter and version-checked hooks.
The adapter provides identity-bound snapshots, typed intentions, guarded native
party/guild invitations, bot-to-bot speech, durable command receipts, and Zaku's
damage floor. `AzerothAI.RestrictBotLogin=1` restricts native bot logins to an
explicit roster of at most 50 identities. `AzerothAI.Enable=1` enables observations
and managed login maintenance. Both settings default off in upstream builds.
`plan-roster.py ACTIVE_JSON OUTPUT_JSON` maps exactly 50 saved identities to
valid Vanilla combinations without changing faction. Death Knights become
Warriors, Alliance Shamans become Paladins, and Horde Paladins become Shamans.
Names remain stable; all characters are created at level 1 using native creation.
`bootstrap.py --root ROOT` waits for successful extraction and native compilation,
configures the isolated Classic world/database, starts a localhost-only game
port, creates the planned characters through the native console, and validates
all 50 identities and levels. It leaves those characters **offline**, writes
`classic-roster-bound.json`, and records progress in `bootstrap-status.json`.
It does not start authentication, change client configuration, or stop Wrath.
`prepare-state.py --root ROOT --live LIVE_REPO` prepares a separate dashboard
database from a consistent SQLite backup. It preserves active personalities and
labels older memories as historical, rebinds native IDs, and resets current-world
observations, command queues, and the population plan. It refuses to overwrite
an existing staging database. Run before starting the staging web runtime.
The staging app requires `GAME_BACKEND=cmangos-classic`. `relay.py --root ROOT
--url http://127.0.0.1:8791 --env-file ROOT/app-staging/.dev.vars` transfers only
validated typed commands into the Classic character database. It never executes
Lua or arbitrary game commands. Native receipts distinguish accepted intentions
from observed gameplay outcomes; interrupted commands are not replayed.
`install-staging-services.py --root ROOT` installs isolated user services for
port 8791, the native relay, and autonomy. Stop manually launched staging
processes first. Services restart on failure and leave the live Wrath services
unchanged. The worker credential file is private to the owner.
The rebuildable item-info cache uses InnoDB with a dynamic row format so the
upstream builder's transaction batches writes with full database durability.
The bootstrap records the existing native bot account range for login
registration and enables activity even when no human player is present.
Automatic population creation remains disabled; the native identity allowlist
still limits managed login to the planned 50.
Before cutover, verify fresh 50-character observations, quest and spell outcomes,
normal resurrection, actual party receipts, native GM commands, authentication,
and rollback. Human player replies require an actual in-game session for an
end-to-end acceptance check. Lua/ALE reflection remains a Wrath-only capability.
Migration safeguards:
- Preserve the verified Wrath backup and the active-50 identity export.
- Maintain 25 Alliance and 25 Horde. Use valid Vanilla race/class combinations.
- Start new Vanilla characters at level 1; do not directly import Wrath inventory,
talent, spell, or quest tables. Blood Elves, Draenei, Death Knights, Alliance
Shamans, and Horde Paladins need explicit compatible replacements.
- Retain personalities and historical memories; reset live observations, GUIDs,
action queues, and progress baselines when binding the new characters.
- Protect human/GM accounts. Inactive managed profiles are cleanup candidates,
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.
`cutover.py --root ROOT` requires a passing build and all 50 fresh staging bots,
backs up stopped Wrath accounts/characters, switches the existing endpoints, and
automatically restores Wrath endpoints if login or population verification fails.
Miniflare metadata databases are excluded from game-state backup selection.
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""Bounded daily backup of Classic persistent accounts, characters and dashboard."""
import argparse,gzip,json,os,pathlib,shutil,sqlite3,subprocess,time
p=argparse.ArgumentParser();p.add_argument('--root',type=pathlib.Path,required=True);a=p.parse_args();root=a.root
base=root/'backups';base.mkdir(mode=0o700,exist_ok=True)
target=base/time.strftime('%Y%m%dT%H%M%SZ',time.gmtime());target.mkdir(mode=0o700)
c=json.loads((root/'database-credentials.json').read_text())
try:
with gzip.open(target/'classic-auth-characters.sql.gz','wb') as out:
p=subprocess.Popen(['docker','exec','-e','MYSQL_PWD','azerothai-classic-db','mysqldump','-uroot','--single-transaction','--set-gtid-purged=OFF','--databases','classicrealmd','classiccharacters'],stdout=subprocess.PIPE,stderr=subprocess.PIPE,env=dict(os.environ,MYSQL_PWD=c['root']))
shutil.copyfileobj(p.stdout,out)
if p.wait():raise RuntimeError('Classic backup failed')
dbs=[p for p in (root/'app-staging/.wrangler/state/v3/d1').rglob('*.sqlite') if p.name!='metadata.sqlite'];assert len(dbs)==1
source=sqlite3.connect(dbs[0]);dest=sqlite3.connect(target/'dashboard.sqlite');source.backup(dest);dest.close();source.close()
shutil.copytree(root/'run/etc',target/'config')
(target/'complete.json').write_text(json.dumps({'at':time.time(),'backend':'cmangos-classic'}))
for old in sorted(base.iterdir(),reverse=True)[3:]:
if old.is_dir() and (old/'complete.json').exists():shutil.rmtree(old)
print('Classic backup complete: '+str(target))
except Exception:
(target/'failed').touch();raise
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Resume isolated Classic startup and native level-one roster creation.
Never switches ports, touches Wrath databases, or starts bot login. Native
characters remain offline until the connector and gameplay checks are ready.
"""
import argparse
import json
import os
import pathlib
import re
import subprocess
import time
def main(root):
credentials=json.loads((root/'database-credentials.json').read_text())
env=dict(os.environ,MYSQL_PWD=credentials['root'])
def mysql(sql):
result=subprocess.run(['docker','exec','-i','-e','MYSQL_PWD','azerothai-classic-db','mysql','-uroot','-N','-B','--raw','classiccharacters'],input=sql,text=True,capture_output=True,env=env)
if result.returncode: raise RuntimeError('Isolated Classic database operation failed: '+result.stderr.replace(credentials['root'],'[redacted]').replace(credentials['password'],'[redacted]'))
return result.stdout.strip()
def status(stage,**extra):
value={'at':time.time(),'stage':stage,'cutover':False,**extra}
temp=root/'bootstrap-status.tmp';temp.write_text(json.dumps(value,indent=2));temp.replace(root/'bootstrap-status.json')
print(stage,flush=True)
def inspect(name):
result=subprocess.run(['docker','inspect','--format','{{json .State}}',name],capture_output=True,text=True)
return json.loads(result.stdout) if result.returncode==0 else None
def configure(name,values):
template=root/'run/etc'/f'{name}.conf.dist'
text=template.read_text()
for key,value in values.items():
line=key+' = '+str(value)
pattern=r'^'+re.escape(key)+r'\s*=.*$'
text=re.sub(pattern,lambda _:line,text,flags=re.M) if re.search(pattern,text,re.M) else text+'\n'+line+'\n'
target=template.with_suffix('');target.write_text(text);target.chmod(0o600)
try:
status('waiting for verified extraction and native build')
deadline=time.time()+6*3600
while time.time()<deadline:
extraction=inspect('azerothai-classic-extract');build=inspect('azerothai-classic-native-build')
if extraction and build and not extraction['Running'] and not build['Running']:
if extraction['ExitCode'] or build['ExitCode']:raise RuntimeError('Extraction or native build failed; see its log')
break
time.sleep(15)
else:raise RuntimeError('Prerequisite wait exceeded six hours')
# Require the verifier, not merely a zero process exit.
for _ in range(12):
stage=json.loads((root/'prerequisites-status.json').read_text())['stage']
if stage.startswith('prerequisites extracted;'):break
time.sleep(5)
else:raise RuntimeError('Extraction verifier has not accepted generated data')
for directory in ['dbc','maps','vmaps','mmaps']:
if not any((root/'extracted'/directory).glob('*')):raise RuntimeError('Missing '+directory)
roster=json.loads((root/'classic-roster-plan.json').read_text())
if len(roster)!=50 or sum(b['team']==0 for b in roster)!=25 or sum(b['team']==1 for b in roster)!=25:raise RuntimeError('Invalid roster plan')
if any(not re.fullmatch('[A-Za-z]{2,12}',b['name']) or b['level']!=1 for b in roster):raise RuntimeError('Invalid native creation parameters')
existing=mysql('SELECT name FROM characters').splitlines()
if any(name not in {b['name'] for b in roster} for name in existing):raise RuntimeError('Unexpected characters in isolated database; refusing to modify')
mysql((root/'adapter/schema.sql').read_text())
engine=mysql("SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA='classiccharacters' AND TABLE_NAME='ai_playerbot_item_info_cache'")
if engine!='InnoDB':mysql('ALTER TABLE ai_playerbot_item_info_cache ENGINE=InnoDB, ROW_FORMAT=DYNAMIC;')
(root/'run/logs').mkdir(exist_ok=True)
values={'DataDir':'"/work/extracted"','LogsDir':'"/work/run/logs"','WorldServerPort':8086,'Console.Enable':1,'Ra.Enable':0,'SOAP.Enabled':0,'AzerothAI.Enable':0,'AzerothAI.RestrictBotLogin':1}
for key,db in [('Login','classicrealmd'),('World','classicmangos'),('Character','classiccharacters'),('Logs','classiclogs')]:values[key+'DatabaseInfo']='"azerothai-classic-db;3306;'+credentials['user']+';'+credentials['password']+';'+db+'"'
configure('mangosd',values)
configure('aiplayerbot',{'AiPlayerbot.Enabled':1,'AiPlayerbot.DisableBotOptimizations':1,'AiPlayerbot.DisableActivityPriorities':1,'AiPlayerbot.botActiveAlone':100,'AiPlayerbot.RandomBotAutologin':0,'AiPlayerbot.RandomBotLoginAtStartup':0,'AiPlayerbot.BotAutologin':0,'AiPlayerbot.RandomBotAutoCreate':0,'AiPlayerbot.MinRandomBots':50,'AiPlayerbot.MaxRandomBots':50,'AiPlayerbot.RandomBotAccountCount':0,'AiPlayerbot.RandomBotAccountPrefix':'AZCLASSIC','AiPlayerbot.RandomBotGuildCount':0,'AiPlayerbot.RandomBotArenaTeamCount':0,'AiPlayerbot.DisableRandomLevels':1,'AiPlayerbot.RandomGearUpgradeEnabled':0,'AiPlayerbot.AutoLearnTrainerSpells':1,'AiPlayerbot.AutoTrainSpells':'yes','AiPlayerbot.RandomBotJoinBG':0,'AiPlayerbot.RandomBotJoinLfg':0,'AiPlayerbot.RandomBotMaps':'0,1'})
fifo=root/'run/console'
if not fifo.exists():os.mkfifo(fifo,0o600)
state=inspect('azerothai-classic-world')
if state is None:
subprocess.run(['docker','run','-d','--name','azerothai-classic-world','--network','azerothai-classic','--cpus','2','--memory','3g','--user','1000:1000','-v',str(root)+':/work','-w','/work/run/bin','-p','127.0.0.1:8086:8086','azerothai-classic-builder:local','bash','-c','exec 3<>/work/run/console; exec ./mangosd -c /work/run/etc/mangosd.conf <&3'],check=True,capture_output=True)
elif not state['Running']:subprocess.run(['docker','start','azerothai-classic-world'],check=True,capture_output=True)
status('starting isolated Classic world; bot login disabled')
for attempt in range(720):
state=inspect('azerothai-classic-world')
if not state or not state['Running']:raise RuntimeError('Classic world exited during startup; inspect container log')
log=subprocess.run(['docker','logs','--since',state['StartedAt'],'azerothai-classic-world'],capture_output=True,text=True).stdout
if 'SERVER STARTUP TIME' in log or 'World initialized' in log:break
if attempt%12==0:status('Classic first startup is building caches; bot login disabled',waitedSeconds=attempt*5)
time.sleep(5)
else:raise RuntimeError('Classic startup not confirmed within sixty minutes; inspect cache progress')
# Restore full durability before any persistent characters are created.
mysql('SET GLOBAL innodb_flush_log_at_trx_commit=1; SET GLOBAL sync_binlog=1;')
status('creating 50 level-one native characters; bot login disabled')
with fifo.open('w') as console:
for bot in roster:
name=bot['name']
if not mysql("SELECT guid FROM characters WHERE name='"+name+"'"):
console.write(f"rndbot create name={name} race={bot['raceId']} class={bot['classId']} level=1 login=false gear=none\n");console.flush()
for _ in range(20):
if mysql("SELECT guid FROM characters WHERE name='"+name+"'"):break
time.sleep(1)
else:raise RuntimeError('Native creation not confirmed for '+name)
bound=[]
for bot in roster:
row=mysql("SELECT guid,account,race,class,level,online FROM characters WHERE name='"+bot['name']+"'").split('\t')
if len(row)!=6:raise RuntimeError('Missing character identity')
guid,account,race,cls,level,online=map(int,row)
if (race,cls,level,online)!=(bot['raceId'],bot['classId'],1,0):raise RuntimeError('Native identity or level validation failed: '+bot['name'])
mysql(f"INSERT INTO azerothai_roster(guid,account_id,name,enabled,plot_armor) VALUES ({guid},{account},'{bot['name']}',0,{int(bot['name']=='Zaku')}) ON DUPLICATE KEY UPDATE account_id=VALUES(account_id),name=VALUES(name)")
bound.append(dict(bot,characterGuid=guid,accountId=account))
if int(mysql('SELECT COUNT(*) FROM characters'))!=50:raise RuntimeError('Population differs from 50')
(root/'classic-roster-bound.json').write_text(json.dumps(bound,indent=2)+'\n')
# Native login authorization reloads this account range on restart.
account_limit=int(mysql("SELECT COALESCE(MAX(CAST(SUBSTRING(username,10) AS UNSIGNED))+1,0) FROM classicrealmd.account WHERE username REGEXP '^AZCLASSIC[0-9]+$'"))
config_path=root/'run/etc/aiplayerbot.conf'
config_path.write_text(re.sub(r'^AiPlayerbot.RandomBotAccountCount\s*=.*$',f'AiPlayerbot.RandomBotAccountCount = {account_limit}',config_path.read_text(),flags=re.M))
status('Classic world ready; 50 level-one characters verified offline; connector and gameplay validation remain',characters=50,alliance=25,horde=25)
except Exception as error:
status('bootstrap blocked',error=str(error).replace(credentials['root'],'[redacted]').replace(credentials['password'],'[redacted]'))
raise
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('--root',type=pathlib.Path,required=True)
main(parser.parse_args().root.resolve())
+5
View File
@@ -0,0 +1,5 @@
-- The upstream item-info builder already batches its writes in a transaction.
-- MyISAM ignores that transaction and syncs each statement to the binary log.
-- Use InnoDB so every restart retains full durability without per-row fsync.
-- Apply to the isolated Classic character database before starting mangosd.
ALTER TABLE ai_playerbot_item_info_cache ENGINE=InnoDB, ROW_FORMAT=DYNAMIC;
+43
View File
@@ -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)<n:
block=sock.recv(n-len(data))
if not block:raise RuntimeError('Authentication connection closed')
data+=block
return data
payload=b'WoW\0'+bytes([1,12,1])+struct.pack('<H',5875)+b'68x\0'+b'niW\0'+b'SUne'+struct.pack('<I',0)+socket.inet_aton('127.0.0.1')+bytes([len(username)])+username
sock.sendall(bytes([0,0])+struct.pack('<H',len(payload))+payload)
header=read(3)
if header!=b'\0\0\0':raise RuntimeError('Classic challenge rejected: '+header.hex())
Braw=read(32);graw=read(read(1)[0]);Nraw=read(read(1)[0]);salt=read(32);read(16)
flags=read(1)[0]
if flags:raise RuntimeError('Unexpected extra authentication factor')
N=int.from_bytes(Nraw,'little');g=int.from_bytes(graw,'little');B=int.from_bytes(Braw,'little')
a=secrets.randbits(256);A=pow(g,a,N).to_bytes(32,'little')
x=int.from_bytes(h(salt+h(username+b':'+password)),'little')
u=int.from_bytes(h(A+Braw),'little')
secret=pow((B-3*pow(g,x,N))%N,a+u*x,N).to_bytes(32,'little')
even=h(secret[::2]);odd=h(secret[1::2]);key=bytes(v for pair in zip(even,odd) for v in pair)
proof=h(bytes(x^y for x,y in zip(h(Nraw),h(graw)))+h(username)+salt+A+Braw+key)
sock.sendall(b'\x01'+A+proof+bytes(20)+b'\0\0')
result=read(2)
if result!=b'\x01\0':raise RuntimeError('Classic password proof rejected: '+result.hex())
if read(20)!=h(A+proof+key):raise RuntimeError('Server password proof did not match')
read(4)
sock.sendall(b'\x10'+bytes(4))
if read(1)!=b'\x10':raise RuntimeError('Unexpected realm-list response')
realms=read(struct.unpack('<H',read(2))[0])
if b'AzerothAI Classic\0' not in realms:raise RuntimeError('Expected Classic realm missing')
print('Classic password proof, server proof and AzerothAI Classic realm listing verified.')
if __name__=='__main__':
p=argparse.ArgumentParser();p.add_argument('--root',type=pathlib.Path,required=True);p.add_argument('--host',default='127.0.0.1');p.add_argument('--port',type=int,default=3725);a=p.parse_args()
verify(a.host,a.port,json.loads((a.root/'classic-gm-credentials.json').read_text()))
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Switch the validated Classic staging realm into the existing local endpoints.
Preserves Wrath containers, user services, database and a final database dump.
On an error restores the former endpoints without overwriting either database.
"""
import argparse,gzip,json,os,pathlib,shutil,sqlite3,subprocess,time,urllib.request
def main(root):
home=pathlib.Path.home();units=home/'.config/systemd/user';app=root/'app-staging'
archive=root/('cutover-backup-'+str(int(time.time())));archive.mkdir(mode=0o700,exist_ok=False)
def run(args,check=True):
p=subprocess.run(args,capture_output=True,text=True)
if check and p.returncode:raise RuntimeError('Command failed: '+' '.join(args[:4])+' '+p.stderr[-500:])
return p.stdout.strip()
def status(stage,**extra):
value={'at':time.time(),'stage':stage,**extra};(root/'cutover-status.json').write_text(json.dumps(value,indent=2));print(stage,flush=True)
def state():return json.load(urllib.request.urlopen('http://127.0.0.1:8791/api/state',timeout=10))
s=state();bots=s['bots']
assert (root/'final-build-passed').exists()
assert len(bots)==50 and all(b['status']=='online' and time.time()-b['world']['at']<20 for b in bots)
assert sum(b['world']['team']==0 for b in bots)==25 and sum(b['world']['team']==1 for b in bots)==25
assert not s['settings']['hardcore']['enabled']
(archive/'classic-state.json').write_text(json.dumps(s))
old=['azerothai-runtime','azerothai-autonomy','azerothai-ale-relay','azerothai-operations.timer','azerothai-backup.timer']
classic=['azerothai-classic-runtime','azerothai-classic-relay','azerothai-classic-autonomy']
for file in units.glob('azerothai*'):shutil.copy2(file,archive/file.name)
shutil.copy2(app/'.dev.vars',archive/'classic.dev.vars');shutil.copy2(root/'staging-worker.env',archive/'staging-worker.env')
changed=False
try:
status('Stopping services for the validated Classic cutover')
changed=True
run(['systemctl','--user','disable','--now',*old],False)
run(['systemctl','--user','stop','azerothai-operations.service','azerothai-backup.service','azerothai-spectator',*classic],False)
run(['docker','stop','-t','30','ac-worldserver','ac-authserver','azerothai-dashboard-proxy'])
run(['docker','rename','azerothai-dashboard-proxy','azerothai-dashboard-proxy-wrath-rollback'])
status('Saving final stopped Wrath character and account databases')
cfg=json.loads(run(['docker','inspect','ac-database']))[0]
values=dict(v.split('=',1) for v in cfg['Config']['Env'] if '=' in v)
password=values.get('MYSQL_ROOT_PASSWORD') or values.get('MARIADB_ROOT_PASSWORD')
env=dict(os.environ,MYSQL_PWD=password)
with gzip.open(archive/'wrath-auth-characters.sql.gz','wb') as out:
proc=subprocess.Popen(['docker','exec','-e','MYSQL_PWD','ac-database','mysqldump','-uroot','--single-transaction','--set-gtid-purged=OFF','--databases','acore_auth','acore_characters'],stdout=subprocess.PIPE,stderr=subprocess.PIPE,env=env)
shutil.copyfileobj(proc.stdout,out)
if proc.wait():raise RuntimeError('Final Wrath database backup failed')
assert (archive/'wrath-auth-characters.sql.gz').stat().st_size>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','rm','-f',name+'-staging'],False)
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=[p for p in (app/'.wrangler/state/v3/d1').rglob('*.sqlite') if p.name!='metadata.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())
+19
View File
@@ -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
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Install the version-checked Classic adapter into an isolated CMaNGOS checkout."""
import argparse
import pathlib
import shutil
def patch(path, anchor, replacement):
text = path.read_text()
if replacement in text:
return
if text.count(anchor) != 1:
raise RuntimeError(f"Upstream source changed: {path.name}")
path.write_text(text.replace(anchor, replacement))
def install(source):
here = pathlib.Path(__file__).resolve().parent
for path in (here / "native").glob("AzerothAI.*"):
shutil.copy2(path, source / "src/game/World" / path.name)
for relative, anchor, invocation in [
("src/game/World/World.cpp", " sMapMgr.Update(diff);", "AzerothAIUpdate(diff);"),
("src/game/Entities/Unit.cpp", " uint32 health = victim->GetHealth();", "damage = AzerothAIBoundDamage(victim, damage);")
]:
path = source / relative
include = '#ifdef ENABLE_PLAYERBOTS\n#include "World/AzerothAI.h"\n#endif\n'
if include not in path.read_text():
path.write_text(include + path.read_text())
patch(path, anchor, anchor + "\n#ifdef ENABLE_PLAYERBOTS\n " + invocation + "\n#endif")
# Upstream treats unknown gear modes as generated equipment. Explicitly
# preserve ordinary character-creation equipment for our level-one roster.
path = source / "src/modules/PlayerBots/playerbot/PlayerbotMgr.cpp"
anchor = ' sRandomPlayerbotMgr.SetValue(botGuid, "create gear", 1, gear);'
patch(path, anchor, ' if (gear != "none")\n ' + anchor)
path = source / "src/game/Entities/CharacterHandler.cpp"
include = '#ifdef ENABLE_PLAYERBOTS\n#include "World/AzerothAI.h"\n#endif\n'
if include not in path.read_text():
path.write_text(include + path.read_text())
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)
install(parser.parse_args().source.resolve())
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Install isolated, restartable Classic dashboard/relay/autonomy user services.
Prepare staging state first and stop any manually started staging processes.
This does not enable game login, start authentication, or change live services.
"""
import argparse
import os
import pathlib
import subprocess
def main(root):
app = root / 'app-staging'
node = pathlib.Path.home() / '.local/bin/node'
for path in [node, app / '.dev.vars', root / 'adapter/relay.py']:
if not path.is_file():
raise RuntimeError('Missing prerequisite: ' + str(path))
if any(c in str(root) for c in [' ', '\n', '"', "'", '%']):
raise RuntimeError('Use a simple absolute staging path')
values = {}
for line in (app / '.dev.vars').read_text().splitlines():
if '=' in line and not line.lstrip().startswith('#'):
key, value = line.split('=', 1)
values[key.strip()] = value.strip().strip('"\'')
token = values['AUTONOMY_TOKEN']
if not token or any(c in token for c in ['\n', '\r', '"', '\\']):
raise RuntimeError('Invalid autonomy token format')
environment = root / 'staging-worker.env'
fd = os.open(environment, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, 'w') as out:
out.write('AUTONOMY_TOKEN="' + token + '"\nAZEROTHAI_URL=http://127.0.0.1:8791\n')
environment.chmod(0o600)
units = pathlib.Path.home() / '.config/systemd/user'
units.mkdir(parents=True, exist_ok=True)
commands = {
'runtime': (f'{node} {app}/scripts/start-local-runtime.mjs', 'Environment=RUNTIME_PORT=8791 RUNTIME_HOST=127.0.0.1\n'),
'relay': (f'/usr/bin/sg docker -c "/usr/bin/python3 {root}/adapter/relay.py --root {root} --url http://127.0.0.1:8791 --env-file {app}/.dev.vars"', ''),
'autonomy': (f'{node} {app}/scripts/autonomy-worker.mjs', f'EnvironmentFile={environment}\n'),
}
names = []
for kind, (command, extra) in commands.items():
name = f'azerothai-classic-{kind}.service'
names.append(name)
after = 'network.target' if kind == 'runtime' else 'azerothai-classic-runtime.service'
(units / name).write_text(f'''[Unit]
Description=AzerothAI Classic staging {kind}
After={after}
[Service]
Type=simple
WorkingDirectory={app}
Environment=PATH={node.parent}:/usr/local/bin:/usr/bin:/bin
{extra}ExecStart={command}
Restart=on-failure
RestartSec=5
TimeoutStopSec=20
[Install]
WantedBy=default.target
''')
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
subprocess.run(['systemctl', '--user', 'enable', '--now', *names], check=True)
subprocess.run(['systemctl', '--user', 'is-active', *names], check=True)
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--root', type=pathlib.Path, required=True)
main(parser.parse_args().root.resolve())
+193
View File
@@ -0,0 +1,193 @@
#include "AzerothAI.h"
#include "Entities/Player.h"
#include "World/World.h"
#include "Server/WorldSession.h"
#include "Server/DBCStores.h"
#include "Globals/ObjectMgr.h"
#include "Globals/SharedDefines.h"
#include "Groups/Group.h"
#include "Config/Config.h"
#include "Database/DatabaseEnv.h"
#include "playerbot/PlayerbotAI.h"
#include "playerbot/PlayerbotAIConfig.h"
#include "playerbot/RandomPlayerbotMgr.h"
#include <atomic>
#include <algorithm>
#include <map>
#include <sstream>
#include <iomanip>
#include <ctime>
namespace {
struct Resident { uint32_t guid, account; std::string name; bool armor; };
std::map<uint32_t, Resident> roster;
std::atomic<uint32_t> armorGuid{0}, armorAccount{0};
std::string sql(std::string text) { CharacterDatabase.escape_string(text); return text; }
std::string quoted(const std::string& text) {
std::ostringstream out; out << '"';
for (unsigned char c : text) {
if (c == '"' || c == '\\') out << '\\' << c;
else if (c < 32) out << "\\u" << std::hex << std::setw(4) << std::setfill('0') << unsigned(c);
else out << c;
}
out << '"'; return out.str();
}
bool identified(Player* p) {
if (!p || !p->IsInWorld()) return false;
auto r = roster.find(p->GetGUIDLow());
return r != roster.end() && p->GetName() == r->second.name && p->GetSession()->GetAccountId() == r->second.account
&& p->GetPlayerbotAI();
}
bool managed(Player* p) { return identified(p) && !p->GetPlayerbotAI()->IsRealPlayer() && !p->GetPlayerbotAI()->HasRealPlayerMaster(); }
bool safeGroup(Player* p) {
Group* g = p->GetGroup(); if (!g) return true;
unsigned count = 0;
for (GroupReference* r = g->GetFirstMember(); r; r = r->next()) {
if (!managed(r->getSource())) return false;
++count;
}
return count == g->GetMembersCount();
}
bool free(Player* p) { return managed(p) && p->IsAlive() && !p->IsInCombat() && !p->IsTaxiFlying() && !p->IsNonMeleeSpellCasted(false); }
bool nearby(Player* a, Player* b, float range) { return free(a) && free(b) && a->GetTeam() == b->GetTeam() && a->GetMapId() == b->GetMapId() && a->GetDistance(b) <= range; }
void receipt(const std::string& id, bool ok, const std::string& result) {
CharacterDatabase.PExecute("UPDATE azerothai_commands SET status='%s',result='%s' WHERE id='%s' AND status='processing'", ok ? "completed" : "failed", sql(result).c_str(), sql(id).c_str());
}
void commands() {
auto q = CharacterDatabase.Query("SELECT id,guid,account_id,name,kind,argument,peer_guid,created_at,peer_account_id,peer_name,expires_at FROM azerothai_commands WHERE status='pending' ORDER BY created_at LIMIT 8");
if (!q) return;
do {
Field* f=q->Fetch(); std::string id=f[0].GetString(), kind=f[4].GetString(), arg=f[5].GetString();
// One world-thread consumer claims durably before any game effect.
if (!CharacterDatabase.DirectPExecute("UPDATE azerothai_commands SET status='processing' WHERE id='%s' AND status='pending'",sql(id).c_str())) continue;
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; }
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); }
receipt(id,p->GetGroup() && p->GetGroup()==peer->GetGroup(),"Native party invitation processed");
} else if (kind=="recruit") {
if (!nearby(p,peer,35) || !p->GetGuildId() || p->GetRank()!=0 || peer->GetGuildId() || peer->GetGuildIdInvited()) {receipt(id,false,"Guild participants unavailable or protected");continue;}
WorldPacket invite(CMSG_GUILD_INVITE,32); invite << std::string(peer->GetName());p->GetSession()->HandleGuildInviteOpcode(invite);
if(peer->GetGuildIdInvited()==p->GetGuildId()){WorldPacket accept(CMSG_GUILD_ACCEPT,0);peer->GetSession()->HandleGuildAcceptOpcode(accept);}
receipt(id,peer->GetGuildId()==p->GetGuildId(),"Native guild invitation processed");
} else if (kind=="leave") {
if (!free(p) || !safeGroup(p) || !p->GetGroup() || std::to_string(p->GetGroup()->GetId())!=arg) { receipt(id,false,"Party changed or protected"); continue; }
WorldPacket packet(CMSG_GROUP_DISBAND,0); p->GetSession()->HandleGroupDisbandOpcode(packet); receipt(id,!p->GetGroup(),"Native departure processed");
} else if (kind=="say") {
if (!nearby(p,peer,25) || arg.empty() || arg.size()>1000) { receipt(id,false,"Conversation participants unavailable nearby"); continue; }
p->Say(arg,p->GetTeam()==ALLIANCE?LANG_COMMON:LANG_ORCISH); receipt(id,true,"In-game speech sent");
} else if (kind=="intent") {
if (!p->IsAlive() || p->IsTaxiFlying() || p->IsInCombat() || !safeGroup(p)) { receipt(id,false,"Native recovery, travel or combat takes priority"); continue; }
PlayerbotAI* ai=p->GetPlayerbotAI(); bool accepted=false;
if (arg=="quest" || arg=="explore") { ai->ChangeStrategy("-stay,+rpg,+grind",BotState::BOT_STATE_NON_COMBAT); ai->DoNextAction(); accepted=true; }
else if (arg=="rest") { accepted=ai->DoSpecificAction("eat") || ai->DoSpecificAction("drink"); }
else if (arg=="trade") { accepted=ai->DoSpecificAction("repair") || ai->DoSpecificAction("sell") || ai->DoSpecificAction("buy"); }
else if (arg=="socialize") { p->HandleEmoteCommand(EMOTE_ONESHOT_WAVE); accepted=true; }
receipt(id,accepted,accepted?"Intention submitted to native AI; outcome requires observation":"Native AI did not accept this intention");
} else receipt(id,false,"Unsupported Classic command");
} while(q->NextRow());
}
void snapshot(Player* p) {
std::ostringstream out; std::string members, nearbyNames; Group* group=p->GetGroup();
if(group) for(GroupReference* r=group->GetFirstMember();r;r=r->next()) if(Player* m=r->getSource()) { if(!members.empty())members+=","; members+=m->GetName(); }
for(auto const& r:roster) if(r.first!=p->GetGUIDLow()) { Player* m=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,r.first)); if(managed(m)&&m->GetTeam()==p->GetTeam()&&m->GetMapId()==p->GetMapId()&&p->GetDistance(m)<=35) { if(!nearbyNames.empty())nearbyNames+=","; nearbyNames+=m->GetName(); } }
out << "{\"isBot\":true,\"characterGuid\":" << p->GetGUIDLow() << ",\"accountId\":" << p->GetSession()->GetAccountId() << ",\"name\":" << quoted(p->GetName())
<< ",\"level\":" << p->GetLevel() << ",\"race\":" << unsigned(p->getRace()) << ",\"class\":" << unsigned(p->getClass())
<< ",\"team\":" << (p->GetTeam()==ALLIANCE?0:1) << ",\"mapId\":" << p->GetMapId() << ",\"zoneId\":" << p->GetZoneId()
<< ",\"x\":" << p->GetPositionX() << ",\"y\":" << p->GetPositionY() << ",\"healthPct\":" << p->GetHealthPercent()
<< ",\"xp\":" << p->GetUInt32Value(PLAYER_XP) << ",\"observedAt\":" << std::time(nullptr)
<< ",\"isDead\":" << (p->IsAlive()?"false":"true") << ",\"isGhost\":" << (p->HasFlag(PLAYER_FLAGS,PLAYER_FLAGS_GHOST)?"true":"false")
<< ",\"inCombat\":" << (p->IsInCombat()?"true":"false") << ",\"casting\":" << (p->IsNonMeleeSpellCasted(false)?"true":"false")
<< ",\"taxi\":" << (p->IsTaxiFlying()?"true":"false") << ",\"humanControlled\":" << (managed(p)?"false":"true")
<< ",\"groupId\":" << quoted(group?std::to_string(group->GetId()):"") << ",\"groupNames\":" << quoted(members)
<< ",\"nearbyBots\":" << quoted(nearbyNames) << ",\"guildId\":" << p->GetGuildId() << ",\"guildRank\":" << p->GetRank()
<< ",\"quests\":[";
bool first=true;for(uint8 i=0;i<MAX_QUEST_LOG_SIZE;++i) { uint32 id=p->GetQuestSlotQuestId(i);if(id){if(!first)out<<',';out<<id;first=false;} }
out << ']'; if(p->GetPowerType()==POWER_MANA && p->GetMaxPower(POWER_MANA)) out << ",\"manaPct\":" << 100.0*p->GetPower(POWER_MANA)/p->GetMaxPower(POWER_MANA);
if(auto area=GetAreaEntryByAreaID(p->GetZoneId())) out << ",\"zoneName\":" << quoted(area->area_name[0]);
out << '}';
CharacterDatabase.PExecute("REPLACE INTO azerothai_snapshots (guid,at,payload) VALUES (%u,UNIX_TIMESTAMP(),'%s')",p->GetGUIDLow(),sql(out.str()).c_str());
}
}
uint32_t AzerothAIBoundDamage(Unit* victim,uint32_t damage) {
if(victim && victim->GetTypeId()==TYPEID_PLAYER && victim->GetGUIDLow()==armorGuid.load()) {
Player* p=static_cast<Player*>(victim);
if(p->GetSession()->GetAccountId()==armorAccount.load() && p->GetPlayerbotAI() && !p->GetPlayerbotAI()->IsRealPlayer()) return std::min(damage,p->GetHealth()>1?p->GetHealth()-1:0u);
}
return damage;
}
bool AzerothAIAllowBotLogin(uint32_t guid) {
if(!sConfig.GetBoolDefault("AzerothAI.RestrictBotLogin",false))return true;
auto count=CharacterDatabase.Query("SELECT COUNT(*) FROM azerothai_roster WHERE enabled=1");
if(!count || count->Fetch()[0].GetUInt32()>50)return false;
auto identity=CharacterDatabase.PQuery("SELECT c.guid FROM characters c JOIN azerothai_roster r ON r.guid=c.guid AND r.account_id=c.account AND r.name=c.name WHERE r.enabled=1 AND c.guid=%u",guid);
return bool(identity);
}
void AzerothAIUpdate(uint32_t diff) {
static uint32_t elapsed=0; elapsed+=diff;if(elapsed<5000)return;elapsed=0;
if(!sConfig.GetBoolDefault("AzerothAI.Enable",false)){armorGuid=0;armorAccount=0;return;}
static bool started=false; if(!started){if(!CharacterDatabase.DirectExecute("UPDATE azerothai_commands SET status='failed',result='Server restarted during execution; outcome unknown, command not replayed' WHERE status='processing'"))return;started=true;}
auto q=CharacterDatabase.Query("SELECT guid,account_id,name,plot_armor FROM azerothai_roster WHERE enabled=1 ORDER BY guid LIMIT 51");
if(!q || q->GetRowCount()>50){roster.clear();armorGuid=0;armorAccount=0;return;}
roster.clear();armorGuid=0;armorAccount=0;
do { Field* f=q->Fetch();Resident r{f[0].GetUInt32(),f[1].GetUInt32(),f[2].GetString(),f[3].GetBool()};if(!r.guid||!r.account)continue;roster.emplace(r.guid,r);if(r.armor && r.name=="Zaku"){armorAccount=r.account;armorGuid=r.guid;} }while(q->NextRow());
// Population is drawn exclusively from the explicit identity allowlist.
static std::map<uint32_t,time_t> loginAttempt;
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<uint32_t,uint32_t> 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());
}
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include <cstdint>
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 <string>
void AzerothAIChat(Player* player, const std::string& message, Player* receiver = nullptr);
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Produce a validated level-one Vanilla roster; never modify live characters."""
import argparse
import collections
import json
import pathlib
import re
RACES = {"Human":1,"Orc":2,"Dwarf":3,"Night Elf":4,"Undead":5,"Tauren":6,"Gnome":7,"Troll":8}
CLASSES = {"Warrior":1,"Paladin":2,"Hunter":3,"Rogue":4,"Priest":5,"Shaman":7,"Mage":8,"Warlock":9,"Druid":11}
ALLOWED = {1:{1,2,4,5,8,9},2:{1,3,4,7,9},3:{1,2,3,4,5},4:{1,3,4,5,11},5:{1,4,5,8,9},6:{1,3,7,11},7:{1,4,8,9},8:{1,3,4,5,7,8}}
ALLIANCE = {"Human","Dwarf","Night Elf","Gnome","Draenei"}
def plan(bots):
if len(bots) != 50 or len({b['name'].lower() for b in bots}) != 50:
raise ValueError("Exactly 50 unique active identities required")
result = []
for b in bots:
if not re.fullmatch(r'[A-Za-z]{2,12}',b['name']):
raise ValueError("Invalid native character name")
race, role = b['race'], b['role']
team = 0 if race in ALLIANCE else 1
if role == 'Death Knight': role = 'Warrior'
if team == 0 and role == 'Shaman': role = 'Paladin'
if team == 1 and role == 'Paladin': role = 'Shaman'
if race == 'Draenei': race = 'Dwarf' if role in ('Hunter','Paladin','Warrior') else 'Human'
if race == 'Blood Elf': race = 'Tauren' if role == 'Shaman' else 'Undead'
race_id, class_id = RACES[race], CLASSES[role]
if class_id not in ALLOWED[race_id]:
raise ValueError(f"Invalid Vanilla combination: {b['name']} {race} {role}")
if (race in ALLIANCE) != (team == 0): raise ValueError('Faction changed')
result.append(dict(id=b['id'],name=b['name'],race=race,role=role,raceId=race_id,classId=class_id,team=team,level=1,
oldRace=b['race'],oldRole=b['role'],oldLevel=b['level']))
if collections.Counter(x['team'] for x in result) != {0:25,1:25}:
raise ValueError('Roster must be 25 Alliance and 25 Horde')
return result
if __name__ == '__main__':
parser=argparse.ArgumentParser()
parser.add_argument('active',type=pathlib.Path)
parser.add_argument('output',type=pathlib.Path)
args=parser.parse_args()
rows=plan(json.loads(args.active.read_text()))
args.output.write_text(json.dumps(rows,indent=2)+'\n')
print('Validated: 50 identities, 25 Alliance, 25 Horde, all start at level 1')
+48
View File
@@ -0,0 +1,48 @@
#!/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':'AZAIADMIN','password':secrets.token_hex(8)}
account_file.write_text(json.dumps(gm));account_file.chmod(0o600)
if gm['username'].startswith('AZCLASSIC'):
gm['username']='AZAIADMIN'
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())
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Prepare the isolated dashboard database after native identities are verified."""
import argparse
import copy
import datetime
import json
import pathlib
import sqlite3
import urllib.request
import uuid
def main(root,live):
stage=root/'app-staging'
bound=json.loads((root/'classic-roster-bound.json').read_text())
assert len(bound)==50 and len({b['characterGuid'] for b in bound})==50
with urllib.request.urlopen('http://127.0.0.1:8787/api/state',timeout=10) as response:state=json.load(response)
# This archive captures memories accumulated since the initial migration export.
(root/'wrath-state-before-dashboard-copy.json').write_text(json.dumps(state))
old={b['id']:b for b in state['bots']}
now=datetime.datetime.now(datetime.timezone.utc).isoformat()
bots=[]
for identity in bound:
bot=copy.deepcopy(old[identity['id']])
bot.update(name=identity['name'],race=identity['race'],role=identity['role'],level=1,zone='Awaiting Classic observation',status='offline',simulationBand='dormant',decisions=0)
for key in ['world','lastDecision','deathMemorySeenId','lastObservation','death']:bot.pop(key,None)
bot['autonomy']={'managed':True,'characterGuid':identity['characterGuid'],'accountId':identity['accountId']}
for memory in bot.get('memories',[]):memory['text']='[Historical memory from the previous Wrath realm; not current Classic progress] '+memory['text']
bot.setdefault('memories',[]).append({'id':str(uuid.uuid4()),'text':f"I am starting a new life at level 1 as a {identity['race']} {identity['role']} in Classic Azeroth. Older memories describe my previous realm.",'importance':10,'source':'observation','at':now})
bot['memories']=bot['memories'][-100:]
bots.append(bot)
state['bots']=bots;state['aleRosterAt']=0;state['revision']=state.get('revision',0)+1
state['events']=[];state.pop('operations',None);state['population']={}
state['settings']['population']['serverMax']=50
state['settings']['hardcore']['enabled']=False
state['community']={'feed':[],'evidence':[],'guilds':[],'partiesEnabled':True,'guildsEnabled':True}
ids={b['id'] for b in bots}
for home in state.get('homes',[]):home['residentIds']=[id for id in home.get('residentIds',[]) if id in ids]
source=list((live/'.wrangler/state/v3/d1').rglob('*.sqlite'))
candidates=[]
for path in source:
with sqlite3.connect(f'file:{path}?mode=ro',uri=True) as db:
if db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='realm_state'").fetchone():candidates.append(path)
if len(candidates)!=1:raise RuntimeError('Expected exactly one live realm database')
source=candidates[0];target=stage/source.relative_to(live)
if target.exists():raise RuntimeError('Staging database already exists; refusing to overwrite')
target.parent.mkdir(parents=True,exist_ok=True)
with sqlite3.connect(f'file:{source}?mode=ro',uri=True) as src,sqlite3.connect(target) as dst:
src.backup(dst)
tables={row[0] for row in dst.execute("SELECT name FROM sqlite_master WHERE type='table'")}
for table in ['ale_commands','ale_events','community_actions','community_clock','conversation_requests','realm_state_chunks']:
if table in tables:dst.execute('DELETE FROM '+table)
text=json.dumps(state,ensure_ascii=False,separators=(',',':'))
parts=[text[i:i+20000] for i in range(0,len(text),20000)];token=str(uuid.uuid4())
dst.execute('CREATE TABLE IF NOT EXISTS realm_state_chunks (token TEXT,part INTEGER,payload TEXT,PRIMARY KEY(token,part))')
dst.executemany('INSERT INTO realm_state_chunks VALUES (?,?,?)',[(token,i,p) for i,p in enumerate(parts)])
dst.execute("UPDATE realm_state SET revision=?,payload=? WHERE id='realm'",(state['revision'],json.dumps({'chunkToken':token,'parts':len(parts)})))
(root/'classic-initial-state.json').write_text(json.dumps(state))
print('Isolated dashboard prepared: 50 preserved personalities, native GUIDs rebound, historical memories labeled, live observations and queues reset')
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('--root',type=pathlib.Path,required=True);parser.add_argument('--live',type=pathlib.Path,required=True)
args=parser.parse_args();main(args.root.resolve(),args.live.resolve())
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Relay typed Classic commands and current native observations; no Lua execution."""
import argparse
import json
import os
import pathlib
import re
import subprocess
import time
import urllib.request
RACES={1:'Human',2:'Orc',3:'Dwarf',4:'Night Elf',5:'Undead',6:'Tauren',7:'Gnome',8:'Troll'}
CLASSES={1:'Warrior',2:'Paladin',3:'Hunter',4:'Rogue',5:'Priest',7:'Shaman',8:'Mage',9:'Warlock',11:'Druid'}
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','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','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() or 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')
return command
def main(root,base,env_file):
credentials=json.loads((root/'database-credentials.json').read_text())
values={}
for line in env_file.read_text().splitlines():
if '=' in line and not line.lstrip().startswith('#'):
key,value=line.split('=',1);values[key.strip()]=value.strip().strip('\"\'')
token=values['ALE_BRIDGE_TOKEN']
env=dict(os.environ,MYSQL_PWD=credentials['root'])
def mysql(sql):
result=subprocess.run(['docker','exec','-i','-e','MYSQL_PWD','azerothai-classic-db','mysql','-uroot','-N','-B','--raw','classiccharacters'],input=sql,text=True,capture_output=True,env=env,timeout=10)
if result.returncode:raise RuntimeError('Classic database query failed')
return [line.split('\t') for line in result.stdout.strip().splitlines()]
def quote(text):return "CONVERT(UNHEX('"+str(text).encode().hex()+"') USING utf8mb4)"
def request(path,payload=None):
body=json.dumps(payload or {}).encode()
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_outcomes=0
while True:
try:
now=int(time.time())
if now-last_snapshot>=5:
rows=mysql('SELECT HEX(payload) FROM azerothai_snapshots WHERE at>=UNIX_TIMESTAMP()-15 ORDER BY guid LIMIT 51')
if len(rows)>50:raise RuntimeError('Classic snapshot population exceeds 50')
payloads=[]
for row in rows:
item=json.loads(bytes.fromhex(row[0]))
item['raceName']=RACES.get(item.get('race'),'Unknown')
item['className']=CLASSES.get(item.get('class'),'Unknown')
payloads.append(item)
roster={'roster':True,'observedAt':now,'characterGuids':[p['characterGuid'] for p in payloads]}
# Rotate eligible nearby pairs; normal app cooldowns bound conversations.
pairs=[]
available=lambda p:not any(p.get(k) for k in ['humanControlled','isDead','isGhost','inCombat','taxi','casting'])
for p in payloads:
if not available(p):continue
for q in payloads:
if p['characterGuid']>=q['characterGuid'] or not available(q):continue
if p['team']==q['team'] and p['mapId']==q['mapId'] and (p['x']-q['x'])**2+(p['y']-q['y'])**2<=625:pairs.append((p,q))
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:
if not re.fullmatch('[0-9a-fA-F-]{36}',id):raise ValueError('Invalid command ID')
try:c=validate(json.loads(body))
except (ValueError,TypeError) as error:complete(id,False,str(error))
else:
mysql(f"INSERT IGNORE INTO azerothai_commands(id,guid,account_id,name,kind,argument,peer_guid,peer_account_id,peer_name,created_at,expires_at) VALUES ({quote(id)},{c['guid']},{c['accountId']},{quote(c['name'])},{quote(c['kind'])},{quote(c['argument'])},{c['peerGuid']},{c['peerAccountId']},{quote(c['peerName'])},{c['expiresAt']-90},{c['expiresAt']})")
for id,status,result in mysql("SELECT id,status,HEX(COALESCE(result,'')) FROM azerothai_commands WHERE delivered=0 AND status IN ('completed','failed') LIMIT 16"):
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_at<UNIX_TIMESTAMP()")
if now-last_cleanup>3600:
mysql("DELETE FROM azerothai_events WHERE delivered=1 AND at<UNIX_TIMESTAMP()-604800")
mysql("DELETE FROM azerothai_commands WHERE delivered=1 AND created_at<UNIX_TIMESTAMP()-604800");last_cleanup=now
except Exception as error:
# Do not print request objects, credentials, or native SQL arguments.
print(type(error).__name__+': '+str(error).replace(token,'[redacted]').replace(credentials['password'],'[redacted]').replace(credentials['root'],'[redacted]'),flush=True)
time.sleep(1)
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('--root',type=pathlib.Path,required=True);parser.add_argument('--url',required=True);parser.add_argument('--env-file',type=pathlib.Path,required=True)
args=parser.parse_args();main(args.root,args.url,args.env_file)
+49
View File
@@ -0,0 +1,49 @@
-- Apply only to the isolated Classic character database.
CREATE TABLE IF NOT EXISTS azerothai_roster (
guid INT UNSIGNED PRIMARY KEY,
account_id INT UNSIGNED NOT NULL,
name VARCHAR(12) NOT NULL UNIQUE,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
plot_armor BOOLEAN NOT NULL DEFAULT FALSE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS azerothai_commands (
id VARCHAR(36) PRIMARY KEY,
guid INT UNSIGNED NOT NULL,
account_id INT UNSIGNED NOT NULL,
name VARCHAR(12) NOT NULL,
kind VARCHAR(20) NOT NULL,
argument TEXT NOT NULL,
peer_guid INT UNSIGNED NOT NULL DEFAULT 0,
peer_account_id INT UNSIGNED NOT NULL DEFAULT 0,
peer_name VARCHAR(12) NOT NULL DEFAULT '',
expires_at BIGINT UNSIGNED NOT NULL,
created_at BIGINT UNSIGNED NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
result TEXT,
delivered BOOLEAN NOT NULL DEFAULT FALSE,
INDEX pending (status,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS azerothai_snapshots (
guid INT UNSIGNED PRIMARY KEY,
at BIGINT UNSIGNED NOT NULL,
payload MEDIUMTEXT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS azerothai_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
at BIGINT UNSIGNED NOT NULL,
kind VARCHAR(100) NOT NULL,
payload TEXT NOT NULL,
delivered BOOLEAN NOT NULL DEFAULT FALSE,
INDEX delivery (delivered,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS azerothai_counters (
guid INT UNSIGNED PRIMARY KEY,
quests BIGINT UNSIGNED NOT NULL DEFAULT 0,
rewarded BIGINT UNSIGNED NOT NULL DEFAULT 0,
lootEvents BIGINT UNSIGNED NOT NULL DEFAULT 0,
lootItems BIGINT UNSIGNED NOT NULL DEFAULT 0,
lootedCopper BIGINT UNSIGNED NOT NULL DEFAULT 0,
nativeDeaths BIGINT UNSIGNED NOT NULL DEFAULT 0,
learnedSpells BIGINT UNSIGNED NOT NULL DEFAULT 0,
resurrections BIGINT UNSIGNED NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Finish isolated Classic prerequisites. Never starts a realm or edits live data."""
import argparse
import hashlib
import json
import pathlib
import shutil
import subprocess
import time
parser = argparse.ArgumentParser()
parser.add_argument('--root', required=True)
args = parser.parse_args()
root = pathlib.Path(args.root).resolve()
def status(stage, **extra):
value = {'at': time.time(), 'stage': stage, 'cutover': False, **extra}
temporary = root / 'prerequisites-status.tmp'
temporary.write_text(json.dumps(value, indent=2))
temporary.replace(root / 'prerequisites-status.json')
print(stage, flush=True)
def run(*command):
return subprocess.check_output(command, text=True).strip()
try:
status('waiting for Classic compilation and client transfer')
deadline = time.monotonic() + 6 * 3600
while True:
state = json.loads(run('docker', 'inspect', 'azerothai-classic-build'))[0]['State']
if not state['Running']:
if state['ExitCode'] != 0:
raise RuntimeError('Classic build failed; inspect docker logs azerothai-classic-build')
break
if time.monotonic() > deadline:
raise RuntimeError('Classic build exceeded six-hour observation window')
time.sleep(30)
manifest = json.loads((root / 'client-manifest.json').read_text())
while not all((root / 'client' / f).exists() and (root / 'client' / f).stat().st_size == v['size'] for f, v in manifest.items()):
if time.monotonic() > deadline:
raise RuntimeError('Client transfer incomplete after six hours')
time.sleep(30)
status('verifying transferred client archives')
for filename, expected in manifest.items():
with (root / 'client' / filename).open('rb') as stream:
actual = hashlib.file_digest(stream, 'sha256').hexdigest()
if actual != expected['sha256']:
raise RuntimeError('Client archive checksum mismatch: ' + filename)
if shutil.disk_usage(root).free < 8 * 1024**3:
raise RuntimeError('Less than 8 GiB free for extracted Vanilla data')
output = root / 'extracted'; output.mkdir(exist_ok=True)
status('extracting Vanilla maps and navigation data; realm not switched')
with (root / 'data-extraction.log').open('w') as log:
subprocess.run([
'docker', 'run', '--name', 'azerothai-classic-extract', '--cpus', '2', '--memory', '4g',
'--user', '1000:1000', '-i', '-v', str(root) + ':/work', '-w', '/work/run/bin/tools',
'azerothai-classic-builder:local', 'bash', './ExtractResources.sh', '/work/client', '/work/extracted'
], input='y\n2\n\nn\nn\n\n', text=True, stdout=log, stderr=subprocess.STDOUT, check=True)
counts = {name: sum(1 for f in (output / name).rglob('*') if f.is_file()) for name in ('dbc', 'maps', 'vmaps', 'mmaps')}
if not all(counts.values()):
raise RuntimeError('An extracted data directory is empty: ' + str(counts))
status('prerequisites extracted; database, connector, roster and gameplay validation remain', files=counts)
except Exception as error:
status('blocked', error=str(error))
raise
+1 -1
View File
@@ -3,4 +3,4 @@ import {writeFileSync,mkdirSync} from 'node:fs';
const result=spawnSync(process.execPath,['node_modules/typescript/bin/tsc','core/state-chunks.ts','core/realm-outcomes.ts','core/realm-insights.ts','core/service.ts','core/seed.ts','core/ale.ts','core/conversation.ts','core/autonomy.ts','--types','@cloudflare/workers-types,node','--outDir','.test-build','--module','commonjs','--target','es2022','--moduleResolution','node','--esModuleInterop','--skipLibCheck','--strict'],{stdio:'inherit'}); const result=spawnSync(process.execPath,['node_modules/typescript/bin/tsc','core/state-chunks.ts','core/realm-outcomes.ts','core/realm-insights.ts','core/service.ts','core/seed.ts','core/ale.ts','core/conversation.ts','core/autonomy.ts','--types','@cloudflare/workers-types,node','--outDir','.test-build','--module','commonjs','--target','es2022','--moduleResolution','node','--esModuleInterop','--skipLibCheck','--strict'],{stdio:'inherit'});
if(result.status)process.exit(result.status); if(result.status)process.exit(result.status);
mkdirSync('.test-build',{recursive:true});writeFileSync('.test-build/package.json','{"type":"commonjs"}'); mkdirSync('.test-build',{recursive:true});writeFileSync('.test-build/package.json','{"type":"commonjs"}');
process.exit(spawnSync(process.execPath,['--test','tests/realm-outcomes.test.cjs','tests/realm-insights.test.cjs','tests/core.test.cjs','tests/hardcore.test.cjs','tests/living-world.test.cjs','tests/ale.test.cjs','tests/brains.test.cjs','tests/conversation.test.cjs','tests/autonomy.test.cjs','tests/community.test.cjs'],{stdio:'inherit'}).status??1); process.exit(spawnSync(process.execPath,['--test','tests/realm-outcomes.test.cjs','tests/realm-insights.test.cjs','tests/core.test.cjs','tests/classic.test.cjs','tests/hardcore.test.cjs','tests/living-world.test.cjs','tests/ale.test.cjs','tests/brains.test.cjs','tests/conversation.test.cjs','tests/autonomy.test.cjs','tests/community.test.cjs'],{stdio:'inherit'}).status??1);
+30
View File
@@ -0,0 +1,30 @@
const {test}=require('node:test');
const assert=require('node:assert/strict');
const {classicCommand,classicMode}=require('../.test-build/classic.js');
const actor={name:'Zaku',autonomy:{managed:true,characterGuid:7,accountId:3}};
test('Classic transport requires explicit backend selection',()=>{
assert.equal(classicMode({}),false);
assert.equal(classicMode({GAME_BACKEND:'cmangos-classic'}),true);
});
test('Classic command rejects absent, zero and unmanaged native identities',()=>{
for(const autonomy of [undefined,{managed:false,characterGuid:7,accountId:3},{managed:true,characterGuid:0,accountId:3},{managed:true,characterGuid:7,accountId:-1}])
assert.throws(()=>classicCommand('intent',{...actor,autonomy},'quest'));
assert.throws(()=>classicCommand('party',actor));
});
test('Classic peer commands bind both accounts and preserve literal speech',()=>{
const peer={name:'Alany',autonomy:{managed:true,characterGuid:8,accountId:4}};
const message='hello "friend"; return 1';
const command=JSON.parse(classicCommand('say',actor,message,peer));
assert.equal(command.argument,message);
assert.equal(command.guid,7);assert.equal(command.accountId,3);
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');
});