108 lines
8.1 KiB
Python
108 lines
8.1 KiB
Python
#!/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())
|