Stage guarded Classic migration and native bot adapter
Core tests / core (push) Successful in 29s

This commit is contained in:
Codex
2026-09-25 22:57:11 +02:00
parent 58b2019015
commit 02d21abcbd
17 changed files with 616 additions and 11 deletions
+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
@@ -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);
+11
View File
@@ -0,0 +1,11 @@
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});
}
+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'}));
} }
+5 -2
View File
@@ -1,3 +1,4 @@
import {classicMode,classicCommand} 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,9 @@ 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)); 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 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);
+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 = {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};
+39 -1
View File
@@ -1,7 +1,8 @@
# Classic migration staging # Classic migration staging
Target: CMaNGOS Classic, client 1.12.1 build 5875, with CMaNGOS Playerbots. Target: CMaNGOS Classic, client 1.12.1 build 5875, with CMaNGOS Playerbots.
This directory does not implement the runtime connector or authorize a cutover. 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 `stage-data.py --root /path/to/isolated/migration` observes an existing
`azerothai-classic-build` container, verifies the transferred MPQs against `azerothai-classic-build` container, verifies the transferred MPQs against
@@ -17,6 +18,43 @@ extraction is only a prerequisite: database import, the Classic control adapter,
the 50-character roster, account protection, and gameplay validation remain. the 50-character roster, account protection, and gameplay validation remain.
The script never starts the new realm, changes the old realm, or deletes bots. 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.
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.
Migration safeguards: Migration safeguards:
- Preserve the verified Wrath backup and the active-50 identity export. - Preserve the verified Wrath backup and the active-50 identity export.
+107
View File
@@ -0,0 +1,107 @@
#!/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())
(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'})
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 _ in range(120):
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
if 'SERVER STARTUP TIME' in log or 'World initialized' in log:break
time.sleep(5)
else:raise RuntimeError('Classic startup not confirmed within ten minutes')
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')
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())
+43
View File
@@ -0,0 +1,43 @@
#!/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;')
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("source", type=pathlib.Path)
install(parser.parse_args().source.resolve())
+144
View File
@@ -0,0 +1,144 @@
#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/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() || 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 (!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);
}
commands();
}
+6
View File
@@ -0,0 +1,6 @@
#pragma once
#include <cstdint>
class Unit;
void AzerothAIUpdate(uint32_t diff);
uint32_t AzerothAIBoundDamage(Unit* victim, uint32_t damage);
bool AzerothAIAllowBotLogin(uint32_t guid);
+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')
+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())
+91
View File
@@ -0,0 +1,91 @@
#!/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'}: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 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
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
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_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)
+30
View File
@@ -0,0 +1,30 @@
-- 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;
+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);
+22
View File
@@ -0,0 +1,22 @@
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);
});