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
26 changed files with 556 additions and 33 deletions
+6
View File
@@ -1,6 +1,7 @@
import { env } from 'cloudflare:workers';
import { z } from 'zod';
import { requireAleOperator,rawAleEnabled } from '@/core/ale-auth';
import {runGM} from '@/core/gm';
import { compileAleOperatorRequest } from '@/core/ale';
import { queueAleCommand } from '@/core/ale-store';
import type { RuntimeConfig } from '@/core/types';
@@ -20,6 +21,11 @@ export async function POST(request:Request){
requireAleOperator(request,config);
const text=await request.text();if(text.length>16000)return Response.json({error:'Request too large'},{status:413});
const body=schema.parse(JSON.parse(text));
if(config.GAME_BACKEND==='cmangos-classic'){
if(body.kind!=='command')throw new Error('Classic supports native GM commands here; Lua/ALE methods belong to the Wrath backend.');
const result=await runGM(body.command,true,true,config);
return Response.json({ok:true,result});
}
const source=compileAleOperatorRequest(body,rawAleEnabled(config));
const queued=await queueAleCommand(env.DB!,source);
return Response.json({ok:true,command:queued},{status:202});
+10
View File
@@ -26,6 +26,7 @@ const STATEFUL_EVENTS=new Set([
'player.close-call',
'player.killed-by-creature',
'player.permanent-death',
'player.death',
'player.repop',
'player.resurrect'
]);
@@ -36,6 +37,11 @@ export async function POST(request:Request){
requireAleBridge(request,config);
const text=await request.text();if(text.length>40000)return Response.json({error:'Event too large'},{status:413});
const body=schema.parse(JSON.parse(text));
const eventId=typeof body.payload.nativeEventId==='string'?body.payload.nativeEventId.slice(0,100):null;
if(eventId){
await env.DB!.prepare('CREATE TABLE IF NOT EXISTS native_event_receipts (id TEXT PRIMARY KEY,at INTEGER)').run();
if(await env.DB!.prepare('SELECT id FROM native_event_receipts WHERE id=?').bind(eventId).first())return Response.json({ok:true,duplicate:true});
}
await recordAleEvent(env.DB!,body.kind,body.payload);
if((body.kind==='player.chat'||body.kind==='player.whisper')&&body.payload.isBot!==true){
@@ -54,6 +60,10 @@ export async function POST(request:Request){
if(zone)await execute(store,{type:'worldTrigger',kind:'zone-change',zone,playerName:String(body.payload.name??'').slice(0,40),detail:'Authoritative ALE human-player zone event'},config);
}
if(eventId){
await env.DB!.prepare('INSERT OR IGNORE INTO native_event_receipts VALUES (?,?)').bind(eventId,Math.floor(Date.now()/1000)).run();
await env.DB!.prepare('DELETE FROM native_event_receipts WHERE at<?').bind(Math.floor(Date.now()/1000)-8*86400).run();
}
return Response.json({ok:true});
}catch(e){
const message=e instanceof Error?e.message:'ALE event failure';
+2 -1
View File
@@ -15,7 +15,8 @@ export async function GET(){
ok:true,
aleActiveRecently,
lastAleAt,
bridgeConfigured:!!(c.BRIDGE_URL&&c.BRIDGE_TOKEN),
gameBackend:c.GAME_BACKEND??'azerothcore',
bridgeConfigured:c.GAME_BACKEND==='cmangos-classic'?!!c.ALE_BRIDGE_TOKEN:!!(c.BRIDGE_URL&&c.BRIDGE_TOKEN),
aleConfigured:!!c.ALE_BRIDGE_TOKEN,
aleOperatorConfigured:!!c.ALE_OPERATOR_TOKEN,
aleRawEnabled:rawAleEnabled(c),
+7 -7
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -9,3 +9,9 @@ export function classicCommand(kind:'party'|'leave'|'recruit'|'intent'|'say',bot
if(['party','recruit','say'].includes(kind)&&(!peer?.autonomy?.managed||!peer.autonomy.characterGuid||!peer.autonomy.accountId))throw new Error('Verified Classic peer required');
return JSON.stringify({version:1,backend:'cmangos-classic',kind,guid:identity.characterGuid,accountId:identity.accountId,name:bot.name,argument,peerGuid:peer?.autonomy?.characterGuid??0,peerAccountId:peer?.autonomy?.accountId??0,peerName:peer?.name??'',expiresAt:Math.floor(Date.now()/1000)+90});
}
export function classicPlayerReply(bot:Bot,request:{speakerGuid?:number;speakerAccountId?:number;speakerName:string;channel:'say'|'whisper'},reply:string){
if(!Number.isInteger(request.speakerGuid)||!request.speakerGuid||request.speakerGuid<1||!Number.isInteger(request.speakerAccountId)||!request.speakerAccountId||request.speakerAccountId<1||!/^[A-Za-z]{2,12}$/.test(request.speakerName))throw new Error('Verified Classic human identity required');
const command=JSON.parse(classicCommand('intent',bot,'socialize'));
return JSON.stringify({...command,kind:request.channel==='whisper'?'reply-whisper':'reply-say',argument:reply,peerGuid:request.speakerGuid,peerAccountId:request.speakerAccountId,peerName:request.speakerName});
}
+20 -1
View File
@@ -1,4 +1,7 @@
import { z } from 'zod';
import {classicCommand} from '../classic';
import {queueAleCommand} from '../ale-store';
import {runGM} from '../gm';
import type { GameConnector,Bot,Decision,RuntimeConfig } from '../types';
const deathSignal=z.object({
@@ -45,5 +48,21 @@ export class AzerothCoreConnector implements GameConnector {
async decide(bot:Bot,decision:Decision,requestId:string){return z.object({accepted:z.literal(true),summary:z.string().max(1000),zone:z.string().max(120).optional()}).parse(await this.request(`/bots/${encodeURIComponent(bot.id)}/intent`,{decision,requestId}));}
}
export class ClassicConnector implements GameConnector {
id='cmangos-classic';
constructor(private config:RuntimeConfig){}
async health(){const result=await runGM('server info',true,true,this.config);return {ok:true,detail:result.output};}
async observe(bot:Bot){
if(!bot.world||Date.now()/1000-bot.world.at>30)throw new Error('Fresh Classic observation required');
return {zone:bot.zone,level:bot.level,alive:!bot.world.isDead&&!bot.world.isGhost,availableActions:bot.personality.activities,summary:JSON.stringify({zone:bot.zone,level:bot.level,health:bot.world.healthPct,mana:bot.world.manaPct,quests:bot.world.quests,combat:bot.world.inCombat,party:bot.world.groupNames})};
}
async lifecycle(){throw new Error('Classic maintains the fixed 50-character roster automatically. Manual lifecycle changes are not enabled.');}
async decide(bot:Bot,decision:Decision){
if(!this.config.DB)throw new Error('Classic command database is unavailable');
const queued=await queueAleCommand(this.config.DB,classicCommand('intent',bot,decision.action));
return {summary:`${bot.name}: requested ${decision.action}; native receipt and observed outcome are pending (${queued.id}).`,zone:bot.zone};
}
}
// Add a connector factory here; personality and memory code remains game-independent.
export const connectors: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 ConversationRequest={
speakerGuid?:number;
speakerAccountId?:number;
socialTurn?:0|1|2|3;
id:string;
createdAt:string;
@@ -16,6 +18,7 @@ export type ConversationRequest={
};
async function ensure(db:D1Database){
await db.prepare('CREATE TABLE IF NOT EXISTS conversation_identity (id TEXT PRIMARY KEY,guid INTEGER NOT NULL,account_id INTEGER NOT NULL)').run();
await db.prepare(`CREATE TABLE IF NOT EXISTS conversation_requests (
id TEXT PRIMARY KEY NOT NULL,
created_at TEXT NOT NULL,
@@ -68,6 +71,9 @@ export async function queueConversationEvent(db:D1Database,kind:string,payload:R
text(payload,'selectedPlayer',40),
text(payload,'nearbyBots',1000)
).run();
if(Number.isInteger(payload.characterGuid)&&Number(payload.characterGuid)>0&&Number.isInteger(payload.accountId)&&Number(payload.accountId)>0){
await db.prepare('INSERT INTO conversation_identity VALUES (?,?,?)').bind(id,payload.characterGuid,payload.accountId).run();
}
return {id,createdAt};
}
@@ -82,6 +88,7 @@ export async function leaseConversation(db:D1Database):Promise<ConversationReque
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>();
if(!row)return null;
const identity=await db.prepare('SELECT guid,account_id FROM conversation_identity WHERE id=?').bind(row.id).first<{guid:number;account_id:number}>();
const startedAt=new Date().toISOString();
const updated=await db.prepare(`UPDATE conversation_requests
@@ -90,6 +97,7 @@ export async function leaseConversation(db:D1Database):Promise<ConversationReque
if(updated.meta.changes!==1)return null;
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,
id:row.id,
createdAt:row.created_at,
+2 -3
View File
@@ -1,4 +1,4 @@
import {classicMode,classicCommand} from './classic';
import {classicMode,classicCommand,classicPlayerReply} from './classic';
import {bondEvidence,socialEntry,communityTick} from './community';
import { z } from 'zod';
import { queueAleCommand } from './ale-store';
@@ -203,8 +203,7 @@ export async function runConversationTick(store:StateStore,db:D1Database,config:
reply=cleanReply(reply);
if(!reply)throw new Error('Conversation reply was empty');
if(classicMode(config)&&request.socialTurn===undefined)throw new Error('Classic player chat delivery is not enabled yet');
const source=classicMode(config)?classicCommand('say',bot,reply,byName(state,request.speakerName)):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 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;');
export async function runGM(raw:string,live:boolean,confirmed:boolean,config:RuntimeConfig){
const command=parseGM(raw);
const parsed=parseGM(raw);
const command=config.GAME_BACKEND==='cmangos-classic'?parsed.replace(/^teleport name /,'tele name '):parsed;
if(!live)return {command,output:`[SIMULATED] Accepted: ${command}\nNo command was sent to a game server.`};
if(!confirmed)throw new Error('Confirm the live GM command before execution');
if(!config.SOAP_URL||!config.SOAP_USER||!config.SOAP_PASSWORD)throw new Error('Configure SOAP_URL, SOAP_USER and SOAP_PASSWORD on the server first');
const r=await fetch(config.SOAP_URL,{method:'POST',headers:{'Content-Type':'text/xml; charset=utf-8',Authorization:`Basic ${btoa(`${config.SOAP_USER}:${config.SOAP_PASSWORD}`)}`},body:`<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 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');
+6 -3
View File
@@ -210,7 +210,7 @@ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,pa
b.memories=b.memories.slice(-100);
}
event(s,'memory',`${b.name} survived a close call in ${b.zone}; the experience was committed to memory.`,b.id);
}else if(kind==='player.killed-by-creature'||kind==='player.permanent-death'){
}else if(kind==='player.killed-by-creature'||kind==='player.permanent-death'||kind==='player.death'){
if(b.plotArmorHpFloor>=1){
event(s,'error',`Plot armor breach: authoritative ALE reported ${b.name} dead. Worldserver damage enforcement must prevent this transition.`,b.id);
}else if(s.settings.hardcore.enabled){
@@ -236,7 +236,10 @@ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,pa
export async function execute(store:StateStore,input:unknown,config:RuntimeConfig={}) {
const a=actionSchema.parse(input);let s=await store.load();
const connector=connectors[s.settings.connector](config);
if(a.type==='health')return {health:await connector.health()};
if(a.type==='health'){
if(config.GAME_BACKEND==='cmangos-classic'){const result=await runGM('server info',true,true,config);return {health:{ok:true,detail:result.output}};}
return {health:await connector.health()};
}
const active=Object.values(s.operations).find(o=>Date.now()-o.started<60000);
if(active)throw new Error('A realm operation is in progress. Wait for it to finish.');
if(Object.keys(s.operations).length){s.operations={};event(s,'error','An interrupted operation expired. Verify live server state before retrying.');}
@@ -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.`);
}else if(a.type==='settings'){
if(a.connector!==s.settings.connector&&s.bots.some(b=>b.status==='online'||b.status==='paused'))throw new Error('Despawn all online and paused bots before switching connectors.');
if(a.connector==='azerothcore'&&(!config.BRIDGE_URL||!config.BRIDGE_TOKEN))throw new Error('AzerothCore bridge is not configured on the server.');
if(a.connector==='azerothcore'&&config.GAME_BACKEND!=='cmangos-classic'&&(!config.BRIDGE_URL||!config.BRIDGE_TOKEN))throw new Error('AzerothCore bridge is not configured on the server.');
if(a.provider!=='deterministic'&&!config.LLM_URL)throw new Error('Local LLM endpoint is not configured on the server.');
s.settings={connector:a.connector,provider:a.provider,model:a.model,brains:a.brains,hardcore:a.hardcore,population:a.population};
event(s,'system',`Realm connector and model settings updated. Hardcore one-life mode is ${a.hardcore.enabled?'enabled':'disabled'}.`);
+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 LLMProvider {id:string; decide(bot:Bot,observation:Observation):Promise<Decision>}
export interface StateStore {load():Promise<State>; save(state:State,expectedRevision:number):Promise<void>}
export type RuntimeConfig = {GAME_BACKEND?:string;SOAP_URL?:string; SOAP_USER?:string; SOAP_PASSWORD?:string; BRIDGE_URL?:string; BRIDGE_TOKEN?:string; ALE_BRIDGE_TOKEN?:string; ALE_OPERATOR_TOKEN?:string; ALE_RAW_EXEC_ENABLED?:string; AUTONOMY_TOKEN?:string; LLM_URL?:string; LLM_API_KEY?:string};
export type RuntimeConfig = {DB?:D1Database;GAME_BACKEND?:string;SOAP_URL?:string; SOAP_USER?:string; SOAP_PASSWORD?:string; BRIDGE_URL?:string; BRIDGE_TOKEN?:string; ALE_BRIDGE_TOKEN?:string; ALE_OPERATOR_TOKEN?:string; ALE_RAW_EXEC_ENABLED?:string; AUTONOMY_TOKEN?:string; LLM_URL?:string; LLM_API_KEY?:string};
+35 -5
View File
@@ -49,11 +49,22 @@ 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.
Remaining cutover gates include live quest/training/survival observations,
event telemetry and human-player conversation parity, dashboard/operator actions,
normal resurrection, actual party acceptance and speech, authentication/client
configuration, and a verified rollback route. Human-player chat delivery is
explicitly disabled in the staged Classic adapter until implemented and tested.
`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:
@@ -68,3 +79,22 @@ Migration safeguards:
not proof that corresponding game characters can safely be deleted.
- Test native bot login, progress, resurrection, safety rules, read-only visitor
access, and current observations before switching the live dashboard or client.
Native outcome hooks now report quest completion/rewards, loot item counts,
looted copper, deaths, resurrection and learned spells. These are separate from
accepted intentions. The configured native auto-learn policy is invoked on
managed level changes; this is not a claim of trainer purchases. Player say and
whisper events bind the sender's native character/account identity and replies
revalidate that identity. Basic GM commands use Classic's own SOAP namespace;
Wrath-only Lua operator calls are rejected explicitly.
`prepare-auth.py --root ROOT` prepares a private Classic service GM account,
copies only ENDAMI's existing SRP credentials into the isolated authentication
database, and disables upstream sample logins. `check-auth.py --root ROOT`
verifies a complete SRP handshake and realm listing without entering the game.
`health.py --root ROOT --url URL` reports host health without Wrath SQL.
`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
+13 -4
View File
@@ -58,11 +58,13 @@ def main(root):
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.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'})
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')
@@ -70,13 +72,16 @@ def main(root):
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 _ in range(120):
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','--tail','60','azerothai-classic-world'],capture_output=True,text=True).stdout
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 ten minutes')
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:
@@ -97,6 +102,10 @@ def main(root):
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]'))
+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
+30
View File
@@ -37,6 +37,36 @@ def install(source):
anchor = 'void PlayerbotHolder::AddPlayerBot(uint32 playerGuid, uint32 masterAccount)\n{'
patch(path, anchor, anchor + '\n if (!AzerothAIAllowBotLogin(playerGuid)) return;')
# Hooks record successful native outcomes, not inferred command success.
player = source / "src/game/Entities/Player.cpp"
loot = source / "src/game/Loot/LootMgr.cpp"
for path in [player, loot]:
if include not in path.read_text():
path.write_text(include + path.read_text())
hooks = [
(player, " // resend quests status directly\n UpdateForQuestWorldObjects();", 'AzerothAIEvent(this,"player.quest.reward",quest_id);'),
(player, "void Player::CompleteQuest(uint32 quest_id)\n{\n if (quest_id)\n {\n SetQuestStatus(quest_id, QUEST_STATUS_COMPLETE);", 'AzerothAIEvent(this,"player.quest.complete",quest_id);'),
(player, " SetDeathState(CORPSE);", 'AzerothAIEvent(this,"player.death");'),
(loot, " plr->ModifyMoney(money_per_player);", 'AzerothAIEvent(plr,"player.loot.money",0,money_per_player);'),
(loot, " player->ModifyMoney(m_gold);", 'AzerothAIEvent(player,"player.loot.money",0,m_gold);'),
]
for path, anchor, call in hooks:
if call:
patch(path, anchor, anchor + "\n#ifdef ENABLE_PLAYERBOTS\n " + call + "\n#endif")
for anchor, call in [
(" if (!applySickness)\n return;", 'AzerothAIEvent(this,"player.resurrect");'),
(" WorldPacket data(SMSG_LEARNED_SPELL, 4);", 'AzerothAIEvent(this,"player.spell.learn",spell_id);'),
(" WorldPacket data(SMSG_ITEM_PUSH_RESULT, (8 + 4 + 4 + 4 + 1 + 4 + 4 + 4 + 4 + 4));", 'if (!received && !created) AzerothAIEvent(this,"player.loot.item",item->GetEntry(),count);'),
]:
patch(player, anchor, "#ifdef ENABLE_PLAYERBOTS\n " + call + "\n#endif\n" + anchor)
chat=source / "src/game/Chat/ChatHandler.cpp"
if include not in chat.read_text():chat.write_text(include+chat.read_text())
for anchor,call in [
(' GetPlayer()->Say(msg, lang);', 'if(lang!=LANG_ADDON && !m_anticheat->IsSilenced()) AzerothAIChat(GetPlayer(),msg);'),
(' if (lang != LANG_ADDON && !m_anticheat->IsSilenced())\n m_anticheat->Whisper(msg, player->GetObjectGuid());', 'if(lang!=LANG_ADDON && !m_anticheat->IsSilenced()) AzerothAIChat(GetPlayer(),msg,player);')
]:patch(chat,anchor,anchor+'\n#ifdef ENABLE_PLAYERBOTS\n '+call+'\n#endif')
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("source", type=pathlib.Path)
@@ -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())
+52 -3
View File
@@ -9,6 +9,7 @@
#include "Config/Config.h"
#include "Database/DatabaseEnv.h"
#include "playerbot/PlayerbotAI.h"
#include "playerbot/PlayerbotAIConfig.h"
#include "playerbot/RandomPlayerbotMgr.h"
#include <atomic>
#include <algorithm>
@@ -61,9 +62,16 @@ void commands() {
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() || p->GetName()!=f[3].GetString() || std::time(nullptr)-f[7].GetUInt32()>90 || std::time(nullptr)>f[10].GetUInt32()) { receipt(id,false,"Stale command or managed identity unavailable"); continue; }
if (f[6].GetUInt32() && (!managed(peer) || peer->GetSession()->GetAccountId()!=f[8].GetUInt32() || peer->GetName()!=f[9].GetString())) {receipt(id,false,"Peer identity changed or protected");continue;}
if (kind=="party") {
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); }
@@ -139,6 +147,47 @@ void AzerothAIUpdate(uint32_t diff) {
for(auto const& pair:roster){auto const&r=pair.second;Player* p=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,r.guid));
if(!p){if(std::time(nullptr)-loginAttempt[r.guid]>=60){auto identity=CharacterDatabase.PQuery("SELECT guid FROM characters WHERE guid=%u AND account=%u AND name='%s'",r.guid,r.account,sql(r.name).c_str());if(identity){sRandomPlayerbotMgr.AddPlayerBot(r.guid,0);loginAttempt[r.guid]=std::time(nullptr);}}continue;}
if(identified(p))snapshot(p);
// Honor the configured native auto-learn policy even for externally
// maintained bot sessions. Do not grant spells through a second path.
static std::map<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());
}
+5
View File
@@ -4,3 +4,8 @@ class Unit;
void AzerothAIUpdate(uint32_t diff);
uint32_t AzerothAIBoundDamage(Unit* victim, uint32_t damage);
bool AzerothAIAllowBotLogin(uint32_t guid);
class Player;
void AzerothAIEvent(Player* player, const char* kind, uint32_t value = 0, uint32_t amount = 1);
#include <string>
void AzerothAIChat(Player* player, const std::string& message, Player* receiver = nullptr);
+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())
+17 -3
View File
@@ -14,12 +14,12 @@ CLASSES={1:'Warrior',2:'Paladin',3:'Hunter',4:'Rogue',5:'Priest',7:'Shaman',8:'M
def validate(command):
if not isinstance(command,dict) or command.get('version')!=1 or command.get('backend')!='cmangos-classic': raise ValueError('Not a typed Classic command')
if command.get('kind') not in {'party','leave','recruit','intent','say'}:raise ValueError('Unsupported command kind')
if command.get('kind') not in {'party','leave','recruit','intent','say','reply-say','reply-whisper'}:raise ValueError('Unsupported command kind')
for key in ['guid','accountId','peerGuid','peerAccountId','expiresAt']:
if type(command.get(key)) is not int or command[key]<0:raise ValueError('Invalid '+key)
if not command['guid'] or not command['accountId'] or not re.fullmatch('[A-Za-z]{2,12}',command.get('name','')):raise ValueError('Invalid actor identity')
if command['peerGuid'] and (not command['peerAccountId'] or not re.fullmatch('[A-Za-z]{2,12}',command.get('peerName',''))):raise ValueError('Invalid peer identity')
if command['kind'] in {'party','recruit','say'} and not command['peerGuid']:raise ValueError('Peer identity required')
if command['kind'] in {'party','recruit','say','reply-say','reply-whisper'} and not command['peerGuid']:raise ValueError('Peer identity required')
if not isinstance(command.get('argument'),str) or len(command['argument'].encode())>1000:raise ValueError('Invalid argument')
if command['expiresAt']<time.time() 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')
@@ -43,7 +43,7 @@ def main(root,base,env_file):
req=urllib.request.Request(base.rstrip('/')+path,data=body,headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'})
with urllib.request.urlopen(req,timeout=10) as response:return response.headers,response.read().decode()
def complete(id,ok,result):request('/api/bridge/ale/result',{'id':id,'ok':ok,'result':result})
last_snapshot=0;last_cleanup=0
last_snapshot=0;last_cleanup=0;last_outcomes=0
while True:
try:
now=int(time.time())
@@ -68,6 +68,19 @@ def main(root,base,env_file):
if pairs:
a,b=pairs[(now//10)%len(pairs)];roster.update(socialA=a['name'],socialB=b['name'],zoneName=a.get('zoneName',''))
request('/api/bridge/ale/snapshots',{'payloads':payloads+[roster]});last_snapshot=now
for event_id,kind,payload in mysql("SELECT id,kind,HEX(payload) FROM azerothai_events WHERE delivered=0 ORDER BY id LIMIT 30"):
data=json.loads(bytes.fromhex(payload));data['nativeEventId']='classic:'+event_id
request('/api/bridge/ale/event',{'kind':kind,'payload':data})
mysql('UPDATE azerothai_events SET delivered=1 WHERE id='+str(int(event_id)))
if now-last_outcomes>=60:
rows=mysql("SELECT r.guid,r.name,COALESCE(c.quests,0),COALESCE(c.rewarded,0),COALESCE(c.lootEvents,0),COALESCE(c.lootItems,0),COALESCE(c.lootedCopper,0),COALESCE(c.nativeDeaths,0) FROM azerothai_roster r LEFT JOIN azerothai_counters c ON c.guid=r.guid WHERE r.enabled=1")
outcomes=[]
for guid,name,quests,rewarded,events,items,copper,deaths in rows:
outcomes.append(dict(guid=int(guid),name=name,quests=int(quests),rewarded=int(rewarded),lootEvents=int(events),lootItems=int(items),lootedCopper=int(copper),nativeDeaths=int(deaths),abandoned=0,partyKills=0))
body=json.dumps(dict(at=now,recoveries=[],outcomes=outcomes)).encode()
req=urllib.request.Request(base.rstrip('/')+'/api/realm-insights',data=body,headers={'Authorization':'Bearer '+values['AUTONOMY_TOKEN'],'Content-Type':'application/json'})
with urllib.request.urlopen(req,timeout=10) as response:response.read()
last_outcomes=now
headers,body=request('/api/bridge/ale/poll')
id=headers.get('X-AzerothAI-Command')
if id:
@@ -80,6 +93,7 @@ def main(root,base,env_file):
complete(id,status=='completed',bytes.fromhex(result).decode());mysql('UPDATE azerothai_commands SET delivered=1 WHERE id='+quote(id))
mysql("UPDATE azerothai_commands SET status='failed',result='Expired before native execution' WHERE status='pending' AND expires_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.
+19
View File
@@ -28,3 +28,22 @@ CREATE TABLE IF NOT EXISTS azerothai_snapshots (
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;
+8
View File
@@ -20,3 +20,11 @@ test('Classic peer commands bind both accounts and preserve literal speech',()=>
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');
});