Enforce hardcore retirement with level-one faction-balanced replacements
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
#!/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) == 1 and int(played) == 0 and int(cls) != 6:
|
||||
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 level-1 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)
|
||||
shutil.copy2(repo / 'ale/azerothai_lifecycle.lua', core / 'azerothai-lua/azerothai_lifecycle.lua')
|
||||
settings = {'AC_AI_PLAYERBOT_DISABLE_RANDOM_LEVELS': '1',
|
||||
'AC_AI_PLAYERBOT_RANDOMBOT_STARTING_LEVEL': '1',
|
||||
'AC_AI_PLAYERBOT_DISABLE_DEATH_KNIGHT_LOGIN': '1'}
|
||||
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)
|
||||
Reference in New Issue
Block a user