#!/usr/bin/env python3 """Back up the realm, seed its lifecycle registry, and install the ALE guard. Run as the Docker-authorized realm owner, with --apply after reviewing the default read-only plan. The world server must be stopped for --apply. This script never deletes characters. """ import argparse import datetime import gzip import json import os from pathlib import Path import re import shutil import subprocess import urllib.request parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--lab', type=Path, required=True) parser.add_argument('--apply', action='store_true') args = parser.parse_args() repo = Path(__file__).resolve().parents[2] lab = args.lab.resolve() config = {} for line in (lab / 'runtime.env').read_text().splitlines(): if '=' in line and not line.startswith('#'): key, value = line.split('=', 1) config[key] = value.strip().strip('"').strip("'") env = os.environ.copy() env['MYSQL_PWD'] = config['DOCKER_DB_ROOT_PASSWORD'] def mysql(sql): return subprocess.check_output(['docker', 'exec', '-e', 'MYSQL_PWD', 'ac-database', 'mysql', '-uroot', '-N', '-B', '-e', sql], env=env, text=True) state = json.load(urllib.request.urlopen('http://127.0.0.1:8787/api/state', timeout=10)) assert state['settings']['hardcore']['enabled'], 'Enable hardcore before installing the guard' raw = mysql("SELECT c.guid,c.name,c.account,c.level,c.totaltime,c.class FROM acore_characters.characters c " "JOIN acore_auth.account a ON a.id=c.account WHERE a.username REGEXP '^RNDBOT[0-9]+$'") characters = [line.split('\t') for line in raw.splitlines() if line] managed = {b.get('autonomy', {}).get('characterGuid'): b for b in state['bots'] if b.get('autonomy', {}).get('managed')} rows = [] counts = {} for guid, name, account, level, played, cls in characters: guid, account = int(guid), int(account) bot = managed.get(guid) if bot: assert bot['name'] == name and bot['autonomy']['accountId'] == account, 'Identity mismatch' protected = name.lower() == 'zaku' or bool(bot and bot.get('plotArmorHpFloor', 0) > 0) if protected: status = 'active' elif bot and bot['status'] == 'dead': assert bot.get('death', {}).get('verified'), 'Unverified death cannot retire a character' status = 'dead' elif bot and bot['status'] != 'retired': status = 'active' elif int(level) == (55 if int(cls) == 6 else 1) and int(played) == 0: status = 'reserve' else: status = 'blocked' counts[status] = counts.get(status, 0) + 1 rows.append((guid, name, account, int(protected), status)) assert any(name.lower() == 'zaku' and protected for _, name, _, protected, _ in rows) assert counts.get('reserve', 0) >= counts.get('dead', 0), 'Insufficient unused starting-level reserve' print('Lifecycle plan:', json.dumps(counts), flush=True) if not args.apply: raise SystemExit(0) running = json.loads(subprocess.check_output(['docker', 'inspect', 'ac-worldserver']))[0]['State']['Running'] assert not running, 'Stop ac-worldserver before applying this migration' os.umask(0o077) backup = lab / ('lifecycle-backup-' + datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ')) backup.mkdir() (backup / 'realm-state.json').write_text(json.dumps(state)) (backup / 'registry-plan.json').write_text(json.dumps(rows)) dump = subprocess.Popen(['docker', 'exec', '-e', 'MYSQL_PWD', 'ac-database', 'mysqldump', '-uroot', '--single-transaction', '--no-tablespaces', '--set-gtid-purged=OFF', '--databases', 'acore_auth', 'acore_characters', 'acore_playerbots'], env=env, stdout=subprocess.PIPE) with gzip.open(backup / 'realm.sql.gz', 'wb') as stream: shutil.copyfileobj(dump.stdout, stream) assert dump.wait() == 0, 'Database backup failed; no migration applied' core = lab / 'azerothcore' override = core / 'docker-compose.override.yml' shutil.copy2(override, backup / override.name) shutil.copytree(core / 'azerothai-lua', backup / 'azerothai-lua') mysql('''CREATE TABLE IF NOT EXISTS acore_auth.azerothai_lifecycle ( guid INT UNSIGNED PRIMARY KEY, name VARCHAR(64) NOT NULL, account_id INT UNSIGNED NOT NULL, protected TINYINT NOT NULL DEFAULT 0, state VARCHAR(16) NOT NULL, died_at TIMESTAMP NULL, activated_at TIMESTAMP NULL, race TINYINT UNSIGNED NOT NULL DEFAULT 0, entry_level TINYINT UNSIGNED NULL ) ENGINE=InnoDB''') for column, definition in [('race', 'TINYINT UNSIGNED NOT NULL DEFAULT 0'), ('entry_level', 'TINYINT UNSIGNED NULL')]: if not mysql(f"SHOW COLUMNS FROM acore_auth.azerothai_lifecycle LIKE '{column}'").strip(): mysql(f'ALTER TABLE acore_auth.azerothai_lifecycle ADD COLUMN {column} {definition}') def text(value): return "CONVERT(UNHEX('" + value.encode().hex() + "') USING utf8mb4)" # Never overwrite existing ledger history on a repeated installation. values = [f"({guid},{text(name)},{account},{protected},{text(status)}," + ("NOW())" if status == 'dead' else "NULL)") for guid, name, account, protected, status in rows] mysql("INSERT IGNORE INTO acore_auth.azerothai_lifecycle " "(guid,name,account_id,protected,state,died_at) VALUES " + ','.join(values)) mysql("UPDATE acore_auth.azerothai_lifecycle l JOIN acore_characters.characters c " "ON c.guid=l.guid AND c.account=l.account_id AND c.name=l.name SET l.race=c.race") # The native factory separates manual AddClass accounts from autonomous accounts. # Make the verified unused reserve selectable without increasing the online cap. mysql("UPDATE acore_playerbots.playerbots_account_type t " "JOIN acore_auth.azerothai_lifecycle l ON l.account_id=t.account_id " "SET t.account_type=1,t.assignment_date=NOW() WHERE l.state='reserve'") source = (repo / 'ale/azerothai_bridge.lua').read_text() current = (core / 'azerothai-lua/azerothai_bridge.lua').read_text() for key in ['POLL_MS', 'PROTECTED', 'KNOWN_BOT_ACCOUNTS']: pattern = r'^local ' + key + r' = .*$' line = re.search(pattern, current, re.M) assert line, key source, n = re.subn(pattern, lambda match: line.group(0), source, flags=re.M) assert n == 1, key (core / 'azerothai-lua/azerothai_bridge.lua').write_text(source) for name in ['azerothai_lifecycle.lua', 'azerothai_survival.lua', 'azerothai_experience.lua', 'azerothai_readiness.lua', 'azerothai_training.lua']: shutil.copy2(repo / 'ale' / name, core / 'azerothai-lua' / name) settings = {'AC_AI_PLAYERBOT_DISABLE_RANDOM_LEVELS': '1', 'AC_AI_PLAYERBOT_RANDOMBOT_STARTING_LEVEL': '1', 'AC_AI_PLAYERBOT_DISABLE_DEATH_KNIGHT_LOGIN': '0'} content = override.read_text() for key, value in settings.items(): pattern = r'(?m)^(\s*)' + key + r':.*$' if re.search(pattern, content): content = re.sub(pattern, lambda m: m.group(1) + key + ': "' + value + '"', content) else: assert ' environment:\n' in content, 'Unexpected compose environment layout' content = content.replace(' environment:\n', ' environment:\n ' + key + ': "' + value + '"\n', 1) override.write_text(content) print('Migration installed. Backup:', backup, flush=True) print('Recreate ac-worldserver to activate; verify the lifecycle startup line and population counts.', flush=True)