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
+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())