Complete Classic telemetry, authenticated player replies and reversible cutover
Core tests / core (push) Successful in 27s
Core tests / core (push) Successful in 27s
This commit is contained in:
@@ -80,3 +80,17 @@ Migration safeguards:
|
||||
not proof that corresponding game characters can safely be deleted.
|
||||
- Test native bot login, progress, resurrection, safety rules, read-only visitor
|
||||
access, and current observations before switching the live dashboard or client.
|
||||
|
||||
Native outcome hooks now report quest completion/rewards, loot item counts,
|
||||
looted copper, deaths, resurrection and learned spells. These are separate from
|
||||
accepted intentions. The configured native auto-learn policy is invoked on
|
||||
managed level changes; this is not a claim of trainer purchases. Player say and
|
||||
whisper events bind the sender's native character/account identity and replies
|
||||
revalidate that identity. Basic GM commands use Classic's own SOAP namespace;
|
||||
Wrath-only Lua operator calls are rejected explicitly.
|
||||
|
||||
`prepare-auth.py --root ROOT` prepares a private Classic service GM account,
|
||||
copies only ENDAMI's existing SRP credentials into the isolated authentication
|
||||
database, and disables upstream sample logins. `check-auth.py --root ROOT`
|
||||
verifies a complete SRP handshake and realm listing without entering the game.
|
||||
`health.py --root ROOT --url URL` reports host health without Wrath SQL.
|
||||
|
||||
@@ -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()))
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/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';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','rename',name,name+'-staging'])
|
||||
common=['--network','azerothai-classic-access','--restart','unless-stopped','--user','1000:1000','-v',str(root)+':/work','-w','/work/run/bin']
|
||||
run(['docker','create','--name','azerothai-classic-world',*common,'--cpus','2','--memory','3g','-p','8086:8086','azerothai-classic-builder:local','bash','-c','exec 3<>/work/run/console; exec ./mangosd -c /work/run/etc/mangosd.conf <&3'])
|
||||
run(['docker','create','--name','azerothai-classic-auth',*common,'--cpus','0.5','--memory','128m','-p','3724:3724','azerothai-classic-builder:local','./realmd','-c','/work/run/etc/realmd.conf'])
|
||||
for name in ['azerothai-classic-world','azerothai-classic-auth']:
|
||||
run(['docker','network','connect','azerothai-classic',name]);run(['docker','start',name])
|
||||
run(['docker','update','--restart','unless-stopped','azerothai-classic-db'])
|
||||
ip=run(['docker','inspect','--format','{{with index .NetworkSettings.Networks "azerothai-classic-access"}}{{.IPAddress}}{{end}}','azerothai-classic-world'])
|
||||
p=app/'.dev.vars';text=p.read_text();import re
|
||||
text=re.sub(r'^SOAP_URL=.*$','SOAP_URL=http://'+ip+':7879',text,flags=re.M);p.write_text(text)
|
||||
# Discard copied Wrath metrics; native Classic counters remain intact.
|
||||
databases=list((app/'.wrangler/state/v3/d1').rglob('*.sqlite'))
|
||||
assert len(databases)==1
|
||||
db=sqlite3.connect(databases[0]);backup=sqlite3.connect(archive/'classic-before-cutover.sqlite');db.backup(backup);backup.close()
|
||||
names=[x[0] for x in db.execute("SELECT name FROM sqlite_master WHERE type='table'")]
|
||||
for name in names:
|
||||
if name.startswith('insights_') or name=='operations_samples':db.execute('DELETE FROM "'+name+'"')
|
||||
db.commit();db.close()
|
||||
for name in classic:
|
||||
p=units/(name+'.service');text=p.read_text().replace('8791','8787').replace('RUNTIME_HOST=127.0.0.1','RUNTIME_HOST=0.0.0.0');p.write_text(text)
|
||||
p=root/'staging-worker.env';p.write_text(p.read_text().replace('8791','8787'))
|
||||
p=units/'azerothai-spectator.service';p.write_text(p.read_text().replace('/home/endami/AzerothAI',str(app)).replace('After=network-online.target azerothai-runtime.service','After=network-online.target azerothai-classic-runtime.service'))
|
||||
run(['systemctl','--user','daemon-reload']);run(['systemctl','--user','enable','--now',*classic,'azerothai-spectator'])
|
||||
run(['docker','run','-d','--name','azerothai-dashboard-proxy','--restart','unless-stopped','--network','azerothai-mini','--add-host','host.docker.internal:host-gateway','--memory','128m','-p','8788:80','-v',str(home/'.config/azerothai/nginx.conf')+':/etc/nginx/conf.d/default.conf:ro','-v',str(app/'dist/client')+':/srv:ro','nginx:alpine'])
|
||||
status('Classic endpoints active; waiting for all 50 fresh observations')
|
||||
for _ in range(36):
|
||||
try:
|
||||
current=json.load(urllib.request.urlopen('http://127.0.0.1:8787/api/state',timeout=5));bs=current['bots']
|
||||
if len(bs)==50 and all(b['status']=='online' and time.time()-b['world']['at']<20 for b in bs):break
|
||||
except Exception:pass
|
||||
time.sleep(5)
|
||||
else:raise RuntimeError('Classic did not restore all 50 fresh observations')
|
||||
assert sum(b['world']['team']==0 for b in bs)==25 and sum(b['world']['team']==1 for b in bs)==25
|
||||
run(['python3',str(root/'adapter/check-auth.py'),'--root',str(root),'--port','3724'])
|
||||
status('Classic cutover complete; 50 fresh bots, 25 Alliance and 25 Horde; login verified',cutover=True,backup=str(archive))
|
||||
except Exception as error:
|
||||
if changed:
|
||||
run(['systemctl','--user','stop',*classic,'azerothai-spectator'],False)
|
||||
run(['docker','stop','azerothai-classic-world','azerothai-classic-auth'],False)
|
||||
if run(['docker','ps','-a','--filter','name=^azerothai-dashboard-proxy-wrath-rollback$','--format','{{.Names}}'],False):
|
||||
run(['docker','rm','-f','azerothai-dashboard-proxy'],False);run(['docker','rename','azerothai-dashboard-proxy-wrath-rollback','azerothai-dashboard-proxy'],False)
|
||||
for file in archive.glob('azerothai*'):shutil.copy2(file,units/file.name)
|
||||
run(['systemctl','--user','daemon-reload'],False)
|
||||
run(['docker','start','ac-worldserver','ac-authserver','azerothai-dashboard-proxy'],False)
|
||||
run(['systemctl','--user','enable','--now',*old,'azerothai-spectator'],False)
|
||||
status('Classic cutover blocked; former Wrath endpoints restored',error=str(error),cutover=False)
|
||||
raise
|
||||
|
||||
if __name__=='__main__':
|
||||
p=argparse.ArgumentParser();p.add_argument('--root',type=pathlib.Path,required=True);main(p.parse_args().root.resolve())
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -62,8 +63,15 @@ void commands() {
|
||||
Player* p=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,f[1].GetUInt32()));
|
||||
Player* peer=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,f[6].GetUInt32()));
|
||||
if (!managed(p) || p->GetSession()->GetAccountId()!=f[2].GetUInt32() || std::string(p->GetName())!=f[3].GetString() || std::time(nullptr)-f[7].GetUInt32()>90 || std::time(nullptr)>f[10].GetUInt32()) { receipt(id,false,"Stale command or managed identity unavailable"); continue; }
|
||||
if (f[6].GetUInt32() && (!managed(peer) || peer->GetSession()->GetAccountId()!=f[8].GetUInt32() || std::string(peer->GetName())!=f[9].GetString())) {receipt(id,false,"Peer identity changed or protected");continue;}
|
||||
if (kind=="party") {
|
||||
bool humanReply=kind=="reply-say" || kind=="reply-whisper";
|
||||
bool peerAllowed=humanReply ? peer && peer->IsInWorld() && (!peer->GetPlayerbotAI() || peer->GetPlayerbotAI()->IsRealPlayer()) : managed(peer);
|
||||
if (f[6].GetUInt32() && (!peerAllowed || peer->GetSession()->GetAccountId()!=f[8].GetUInt32() || std::string(peer->GetName())!=f[9].GetString())) {receipt(id,false,"Peer identity changed or protected");continue;}
|
||||
if (humanReply) {
|
||||
if (arg.empty() || arg.size()>1000 || !peer || p->GetTeam()!=peer->GetTeam() || (kind=="reply-say" && (p->GetMapId()!=peer->GetMapId() || p->GetDistance(peer)>25))) {receipt(id,false,"Player conversation no longer available");continue;}
|
||||
if(kind=="reply-whisper")p->Whisper(arg,LANG_UNIVERSAL,peer->GetObjectGuid());
|
||||
else p->Say(arg,p->GetTeam()==ALLIANCE?LANG_COMMON:LANG_ORCISH);
|
||||
receipt(id,true,"In-game player reply sent");
|
||||
} else if (kind=="party") {
|
||||
if (!nearby(p,peer,35) || !safeGroup(p) || !safeGroup(peer) || peer->GetGroup() || peer->GetGroupInvite() || p->GetGroupInvite() || (p->GetGroup() && (p->GetGroup()->GetMembersCount()>=5 || p->GetGroup()->GetLeaderGuid()!=p->GetObjectGuid()))) { receipt(id,false,"Party participants unavailable or protected"); continue; }
|
||||
WorldPacket invite(CMSG_GROUP_INVITE,32); invite << std::string(peer->GetName()); p->GetSession()->HandleGroupInviteOpcode(invite);
|
||||
if (peer->GetGroupInvite()) { WorldPacket accept(CMSG_GROUP_ACCEPT,0); peer->GetSession()->HandleGroupAcceptOpcode(accept); }
|
||||
@@ -139,6 +147,47 @@ void AzerothAIUpdate(uint32_t diff) {
|
||||
for(auto const& pair:roster){auto const&r=pair.second;Player* p=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,r.guid));
|
||||
if(!p){if(std::time(nullptr)-loginAttempt[r.guid]>=60){auto identity=CharacterDatabase.PQuery("SELECT guid FROM characters WHERE guid=%u AND account=%u AND name='%s'",r.guid,r.account,sql(r.name).c_str());if(identity){sRandomPlayerbotMgr.AddPlayerBot(r.guid,0);loginAttempt[r.guid]=std::time(nullptr);}}continue;}
|
||||
if(identified(p))snapshot(p);
|
||||
// Honor the configured native auto-learn policy even for externally
|
||||
// maintained bot sessions. Do not grant spells through a second path.
|
||||
static std::map<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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare isolated Classic authentication, preserving ENDAMI's SRP credentials.
|
||||
Does not stop Wrath or alter any Wrath account. Secrets are never printed.
|
||||
"""
|
||||
import argparse,base64,hashlib,json,os,pathlib,re,secrets,subprocess
|
||||
|
||||
def main(root):
|
||||
credentials=json.loads((root/'database-credentials.json').read_text())
|
||||
def query(container,password,sql):
|
||||
result=subprocess.run(['docker','exec','-i','-e','MYSQL_PWD',container,'mysql','-uroot','-N','-B'],input=sql,text=True,capture_output=True,env=dict(os.environ,MYSQL_PWD=password))
|
||||
if result.returncode:raise RuntimeError('Authentication database operation failed')
|
||||
return result.stdout.strip()
|
||||
def classic(sql):return query('azerothai-classic-db',credentials['root'],sql)
|
||||
account_file=root/'classic-gm-credentials.json'
|
||||
if account_file.exists():gm=json.loads(account_file.read_text())
|
||||
else:
|
||||
gm={'username':'AZCLASSICGM','password':secrets.token_hex(8)}
|
||||
account_file.write_text(json.dumps(gm));account_file.chmod(0o600)
|
||||
salt=secrets.token_bytes(32)
|
||||
digest=hashlib.sha1((gm['username'].upper()+':'+gm['password'].upper()).encode()).digest()
|
||||
exponent=int.from_bytes(hashlib.sha1(salt+digest).digest(),'little')
|
||||
verifier=pow(7,exponent,int('894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7',16))
|
||||
assert re.fullmatch('[A-Z0-9]+',gm['username'])
|
||||
classic(f"INSERT INTO classicrealmd.account(username,s,v,gmlevel) VALUES ('{gm['username']}','{salt[::-1].hex()}','{verifier:X}',3) ON DUPLICATE KEY UPDATE gmlevel=3,s=VALUES(s),v=VALUES(v);")
|
||||
# Disable upstream sample logins without deleting accounts or characters.
|
||||
classic("UPDATE classicrealmd.account SET locked=1,lockedIp='disabled' WHERE id IN (1,2,3,4) AND username IN ('ADMINISTRATOR','GAMEMASTER','MODERATOR','PLAYER');")
|
||||
config=json.loads(subprocess.check_output(['docker','inspect','ac-database']))[0]
|
||||
env=dict(v.split('=',1) for v in config['Config']['Env'] if '=' in v)
|
||||
password=env.get('MYSQL_ROOT_PASSWORD') or env.get('MARIADB_ROOT_PASSWORD')
|
||||
row=query('ac-database',password,"SELECT a.username,HEX(a.salt),HEX(a.verifier),MAX(x.gmlevel) FROM acore_auth.account a JOIN acore_auth.account_access x ON x.id=a.id WHERE a.username='ENDAMI' GROUP BY a.id;").split('\t')
|
||||
if len(row)!=4 or row[0]!='ENDAMI':raise RuntimeError('Expected existing human GM account was not found')
|
||||
salt=bytes.fromhex(row[1])[::-1].hex();verifier=bytes.fromhex(row[2])[::-1].hex()
|
||||
classic(f"INSERT INTO classicrealmd.account(username,s,v,gmlevel) VALUES ('ENDAMI','{salt}','{verifier}',3) ON DUPLICATE KEY UPDATE s=VALUES(s),v=VALUES(v),gmlevel=3;")
|
||||
classic("UPDATE classicrealmd.realmlist SET name='AzerothAI Classic',address='10.0.0.125',port=8086,realmflags=0,allowedSecurityLevel=0 WHERE id=1;")
|
||||
text=(root/'run/etc/realmd.conf.dist').read_text()
|
||||
values={'LoginDatabaseInfo':'"azerothai-classic-db;3306;'+credentials['user']+';'+credentials['password']+';classicrealmd"','RealmServerPort':'3724','BindIP':'"0.0.0.0"','LogsDir':'"/work/run/logs"'}
|
||||
for key,value in values.items():
|
||||
pattern=r'^'+re.escape(key)+r'\s*=.*$';line=key+' = '+value
|
||||
text=re.sub(pattern,lambda _:line,text,flags=re.M) if re.search(pattern,text,re.M) else text+'\n'+line+'\n'
|
||||
path=root/'run/etc/realmd.conf';path.write_text(text);path.chmod(0o600)
|
||||
print('Classic GM credentials prepared; ENDAMI password verifier preserved; sample logins disabled.')
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser();parser.add_argument('--root',type=pathlib.Path,required=True)
|
||||
main(parser.parse_args().root.resolve())
|
||||
+17
-3
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user