Fix Classic bot login, native commands and unattended activity
Core tests / core (push) Successful in 26s
Core tests / core (push) Successful in 26s
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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]'))
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user