Fix Classic bot login, native commands and unattended activity
Core tests / core (push) Successful in 26s

This commit is contained in:
Codex
2026-09-25 17:26:54 -07:00
parent f5099cd338
commit d7d4e899f1
5 changed files with 96 additions and 3 deletions
+12
View File
@@ -49,6 +49,18 @@ validated typed commands into the Classic character database. It never executes
Lua or arbitrary game commands. Native receipts distinguish accepted intentions Lua or arbitrary game commands. Native receipts distinguish accepted intentions
from observed gameplay outcomes; interrupted commands are not replayed. from observed gameplay outcomes; interrupted commands are not replayed.
`install-staging-services.py --root ROOT` installs isolated user services for
port 8791, the native relay, and autonomy. Stop manually launched staging
processes first. Services restart on failure and leave the live Wrath services
unchanged. The worker credential file is private to the owner.
The rebuildable item-info cache uses InnoDB with a dynamic row format so the
upstream builder's transaction batches writes with full database durability.
The bootstrap records the existing native bot account range for login
registration and enables activity even when no human player is present.
Automatic population creation remains disabled; the native identity allowlist
still limits managed login to the planned 50.
Remaining cutover gates include live quest/training/survival observations, Remaining cutover gates include live quest/training/survival observations,
event telemetry and human-player conversation parity, dashboard/operator actions, event telemetry and human-player conversation parity, dashboard/operator actions,
normal resurrection, actual party acceptance and speech, authentication/client normal resurrection, actual party acceptance and speech, authentication/client
+7 -1
View File
@@ -58,11 +58,13 @@ def main(root):
existing=mysql('SELECT name FROM characters').splitlines() 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') 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()) mysql((root/'adapter/schema.sql').read_text())
engine=mysql("SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA='classiccharacters' AND TABLE_NAME='ai_playerbot_item_info_cache'")
if engine!='InnoDB':mysql('ALTER TABLE ai_playerbot_item_info_cache ENGINE=InnoDB, ROW_FORMAT=DYNAMIC;')
(root/'run/logs').mkdir(exist_ok=True) (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} 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+'"' 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('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'}) configure('aiplayerbot',{'AiPlayerbot.Enabled':1,'AiPlayerbot.DisableBotOptimizations':1,'AiPlayerbot.DisableActivityPriorities':1,'AiPlayerbot.botActiveAlone':100,'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' fifo=root/'run/console'
if not fifo.exists():os.mkfifo(fifo,0o600) if not fifo.exists():os.mkfifo(fifo,0o600)
state=inspect('azerothai-classic-world') state=inspect('azerothai-classic-world')
@@ -100,6 +102,10 @@ def main(root):
bound.append(dict(bot,characterGuid=guid,accountId=account)) bound.append(dict(bot,characterGuid=guid,accountId=account))
if int(mysql('SELECT COUNT(*) FROM characters'))!=50:raise RuntimeError('Population differs from 50') 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') (root/'classic-roster-bound.json').write_text(json.dumps(bound,indent=2)+'\n')
# Native login authorization reloads this account range on restart.
account_limit=int(mysql("SELECT COALESCE(MAX(CAST(SUBSTRING(username,10) AS UNSIGNED))+1,0) FROM classicrealmd.account WHERE username REGEXP '^AZCLASSIC[0-9]+$'"))
config_path=root/'run/etc/aiplayerbot.conf'
config_path.write_text(re.sub(r'^AiPlayerbot.RandomBotAccountCount\s*=.*$',f'AiPlayerbot.RandomBotAccountCount = {account_limit}',config_path.read_text(),flags=re.M))
status('Classic world ready; 50 level-one characters verified offline; connector and gameplay validation remain',characters=50,alliance=25,horde=25) 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: except Exception as error:
status('bootstrap blocked',error=str(error).replace(credentials['root'],'[redacted]').replace(credentials['password'],'[redacted]')) status('bootstrap blocked',error=str(error).replace(credentials['root'],'[redacted]').replace(credentials['password'],'[redacted]'))
+5
View File
@@ -0,0 +1,5 @@
-- The upstream item-info builder already batches its writes in a transaction.
-- MyISAM ignores that transaction and syncs each statement to the binary log.
-- Use InnoDB so every restart retains full durability without per-row fsync.
-- Apply to the isolated Classic character database before starting mangosd.
ALTER TABLE ai_playerbot_item_info_cache ENGINE=InnoDB, ROW_FORMAT=DYNAMIC;
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Install isolated, restartable Classic dashboard/relay/autonomy user services.
Prepare staging state first and stop any manually started staging processes.
This does not enable game login, start authentication, or change live services.
"""
import argparse
import os
import pathlib
import subprocess
def main(root):
app = root / 'app-staging'
node = pathlib.Path.home() / '.local/bin/node'
for path in [node, app / '.dev.vars', root / 'adapter/relay.py']:
if not path.is_file():
raise RuntimeError('Missing prerequisite: ' + str(path))
if any(c in str(root) for c in [' ', '\n', '"', "'", '%']):
raise RuntimeError('Use a simple absolute staging path')
values = {}
for line in (app / '.dev.vars').read_text().splitlines():
if '=' in line and not line.lstrip().startswith('#'):
key, value = line.split('=', 1)
values[key.strip()] = value.strip().strip('"\'')
token = values['AUTONOMY_TOKEN']
if not token or any(c in token for c in ['\n', '\r', '"', '\\']):
raise RuntimeError('Invalid autonomy token format')
environment = root / 'staging-worker.env'
fd = os.open(environment, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, 'w') as out:
out.write('AUTONOMY_TOKEN="' + token + '"\nAZEROTHAI_URL=http://127.0.0.1:8791\n')
environment.chmod(0o600)
units = pathlib.Path.home() / '.config/systemd/user'
units.mkdir(parents=True, exist_ok=True)
commands = {
'runtime': (f'{node} {app}/scripts/start-local-runtime.mjs', 'Environment=RUNTIME_PORT=8791 RUNTIME_HOST=127.0.0.1\n'),
'relay': (f'/usr/bin/sg docker -c "/usr/bin/python3 {root}/adapter/relay.py --root {root} --url http://127.0.0.1:8791 --env-file {app}/.dev.vars"', ''),
'autonomy': (f'{node} {app}/scripts/autonomy-worker.mjs', f'EnvironmentFile={environment}\n'),
}
names = []
for kind, (command, extra) in commands.items():
name = f'azerothai-classic-{kind}.service'
names.append(name)
after = 'network.target' if kind == 'runtime' else 'azerothai-classic-runtime.service'
(units / name).write_text(f'''[Unit]
Description=AzerothAI Classic staging {kind}
After={after}
[Service]
Type=simple
WorkingDirectory={app}
Environment=PATH={node.parent}:/usr/local/bin:/usr/bin:/bin
{extra}ExecStart={command}
Restart=on-failure
RestartSec=5
TimeoutStopSec=20
[Install]
WantedBy=default.target
''')
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
subprocess.run(['systemctl', '--user', 'enable', '--now', *names], check=True)
subprocess.run(['systemctl', '--user', 'is-active', *names], check=True)
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--root', type=pathlib.Path, required=True)
main(parser.parse_args().root.resolve())
+2 -2
View File
@@ -61,8 +61,8 @@ void commands() {
if (!CharacterDatabase.DirectPExecute("UPDATE azerothai_commands SET status='processing' WHERE id='%s' AND status='pending'",sql(id).c_str())) continue; if (!CharacterDatabase.DirectPExecute("UPDATE azerothai_commands SET status='processing' WHERE id='%s' AND status='pending'",sql(id).c_str())) continue;
Player* p=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,f[1].GetUInt32())); Player* p=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,f[1].GetUInt32()));
Player* peer=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,f[6].GetUInt32())); Player* peer=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,f[6].GetUInt32()));
if (!managed(p) || p->GetSession()->GetAccountId()!=f[2].GetUInt32() || p->GetName()!=f[3].GetString() || std::time(nullptr)-f[7].GetUInt32()>90 || std::time(nullptr)>f[10].GetUInt32()) { receipt(id,false,"Stale command or managed identity unavailable"); continue; } if (!managed(p) || p->GetSession()->GetAccountId()!=f[2].GetUInt32() || std::string(p->GetName())!=f[3].GetString() || std::time(nullptr)-f[7].GetUInt32()>90 || std::time(nullptr)>f[10].GetUInt32()) { receipt(id,false,"Stale command or managed identity unavailable"); continue; }
if (f[6].GetUInt32() && (!managed(peer) || peer->GetSession()->GetAccountId()!=f[8].GetUInt32() || peer->GetName()!=f[9].GetString())) {receipt(id,false,"Peer identity changed or protected");continue;} if (f[6].GetUInt32() && (!managed(peer) || peer->GetSession()->GetAccountId()!=f[8].GetUInt32() || std::string(peer->GetName())!=f[9].GetString())) {receipt(id,false,"Peer identity changed or protected");continue;}
if (kind=="party") { if (kind=="party") {
if (!nearby(p,peer,35) || !safeGroup(p) || !safeGroup(peer) || peer->GetGroup() || peer->GetGroupInvite() || p->GetGroupInvite() || (p->GetGroup() && (p->GetGroup()->GetMembersCount()>=5 || p->GetGroup()->GetLeaderGuid()!=p->GetObjectGuid()))) { receipt(id,false,"Party participants unavailable or protected"); continue; } if (!nearby(p,peer,35) || !safeGroup(p) || !safeGroup(peer) || peer->GetGroup() || peer->GetGroupInvite() || p->GetGroupInvite() || (p->GetGroup() && (p->GetGroup()->GetMembersCount()>=5 || p->GetGroup()->GetLeaderGuid()!=p->GetObjectGuid()))) { receipt(id,false,"Party participants unavailable or protected"); continue; }
WorldPacket invite(CMSG_GROUP_INVITE,32); invite << std::string(peer->GetName()); p->GetSession()->HandleGroupInviteOpcode(invite); WorldPacket invite(CMSG_GROUP_INVITE,32); invite << std::string(peer->GetName()); p->GetSession()->HandleGroupInviteOpcode(invite);