Files
2026-09-24 09:24:57 +12:00

135 lines
7.1 KiB
Python

#!/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)