diff --git a/deploy/classic/README.md b/deploy/classic/README.md index 0818500..cffc2a4 100644 --- a/deploy/classic/README.md +++ b/deploy/classic/README.md @@ -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 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, event telemetry and human-player conversation parity, dashboard/operator actions, normal resurrection, actual party acceptance and speech, authentication/client diff --git a/deploy/classic/bootstrap.py b/deploy/classic/bootstrap.py index 11bb01b..8fb0acc 100644 --- a/deploy/classic/bootstrap.py +++ b/deploy/classic/bootstrap.py @@ -58,11 +58,13 @@ def main(root): 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') 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) 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+'"' 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' if not fifo.exists():os.mkfifo(fifo,0o600) state=inspect('azerothai-classic-world') @@ -100,6 +102,10 @@ def main(root): bound.append(dict(bot,characterGuid=guid,accountId=account)) 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') + # 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) except Exception as error: status('bootstrap blocked',error=str(error).replace(credentials['root'],'[redacted]').replace(credentials['password'],'[redacted]')) diff --git a/deploy/classic/cache-storage.sql b/deploy/classic/cache-storage.sql new file mode 100644 index 0000000..e156822 --- /dev/null +++ b/deploy/classic/cache-storage.sql @@ -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; diff --git a/deploy/classic/install-staging-services.py b/deploy/classic/install-staging-services.py new file mode 100644 index 0000000..5bc07ee --- /dev/null +++ b/deploy/classic/install-staging-services.py @@ -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()) diff --git a/deploy/classic/native/AzerothAI.cpp b/deploy/classic/native/AzerothAI.cpp index 2ec9fea..b14bcbe 100644 --- a/deploy/classic/native/AzerothAI.cpp +++ b/deploy/classic/native/AzerothAI.cpp @@ -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; Player* p=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,f[1].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 (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 (!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() || std::string(peer->GetName())!=f[9].GetString())) {receipt(id,false,"Peer identity changed or protected");continue;} 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; } WorldPacket invite(CMSG_GROUP_INVITE,32); invite << std::string(peer->GetName()); p->GetSession()->HandleGroupInviteOpcode(invite);