Compare commits
2
Commits
1f9ad52d07
...
02d21abcbd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02d21abcbd | ||
|
|
58b2019015 |
@@ -1,3 +1,4 @@
|
||||
import {classicMode} from '@/core/classic';
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { requireAleBridge } from '@/core/ale-auth';
|
||||
import { pollAleCommand } from '@/core/ale-store';
|
||||
@@ -9,8 +10,8 @@ async function handlePoll(request:Request){
|
||||
try{
|
||||
requireAleBridge(request,config);
|
||||
const command=await pollAleCommand(env.DB!);
|
||||
if(!command)return new Response('return',{status:200,headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store'}});
|
||||
return new Response(wrapAleCommand(command.id,command.source),{status:200,headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store','X-AzerothAI-Command':command.id}});
|
||||
if(!command)return new Response(classicMode(config)?'null':'return',{status:200,headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store'}});
|
||||
return new Response(classicMode(config)?command.source:wrapAleCommand(command.id,command.source),{status:200,headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store','X-AzerothAI-Command':command.id}});
|
||||
}catch(e){
|
||||
const message=e instanceof Error?e.message:'ALE bridge failure';
|
||||
return Response.json({error:message},{status:message.startsWith('Unauthorized')?401:500});
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
import {classicMode,classicCommand} from './classic';
|
||||
import {startIntentCheck} from './intent-evidence';
|
||||
import {intentCompanions,partyIntentBusy,recordPartyIntent,compileCommunityAction} from './community';
|
||||
import {DeterministicProvider,providerForBot} from './llm';
|
||||
@@ -188,7 +189,7 @@ export async function runLiveAutonomyTick(
|
||||
// Recheck eligibility after inference; native guards recheck once more at execution.
|
||||
let peer=decision.action==='socialize'?intentCompanions(s,bot)[0]:undefined;
|
||||
if(peer&&await partyIntentBusy(db,bot,peer))peer=undefined;
|
||||
const source=peer?compileCommunityAction('party',bot,peer):compileLivePlayerbotIntent(bot.name,decision);
|
||||
const source=classicMode(config)?(peer?classicCommand('party',bot,'',peer):classicCommand('intent',bot,decision.action)):(peer?compileCommunityAction('party',bot,peer):compileLivePlayerbotIntent(bot.name,decision));
|
||||
const queued=await queueAleCommand(db,source);
|
||||
if(peer)await recordPartyIntent(db,s,queued.id,bot,peer);
|
||||
const check=startIntentCheck(bot,decision,queued.id,Date.now()/1000,peer?.name);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type {Bot,RuntimeConfig} from './types';
|
||||
|
||||
export const classicMode=(config:RuntimeConfig)=>config.GAME_BACKEND==='cmangos-classic';
|
||||
|
||||
/** Typed native actions only; the Classic bridge never evaluates Lua. */
|
||||
export function classicCommand(kind:'party'|'leave'|'recruit'|'intent'|'say',bot:Bot,argument='',peer?:Bot){
|
||||
const identity=bot.autonomy;
|
||||
if(!identity?.managed||!Number.isInteger(identity.characterGuid)||!identity.characterGuid||identity.characterGuid<1||!Number.isInteger(identity.accountId)||!identity.accountId||identity.accountId<1)throw new Error('Verified Classic character identity required');
|
||||
if(['party','recruit','say'].includes(kind)&&(!peer?.autonomy?.managed||!peer.autonomy.characterGuid||!peer.autonomy.accountId))throw new Error('Verified Classic peer required');
|
||||
return JSON.stringify({version:1,backend:'cmangos-classic',kind,guid:identity.characterGuid,accountId:identity.accountId,name:bot.name,argument,peerGuid:peer?.autonomy?.characterGuid??0,peerAccountId:peer?.autonomy?.accountId??0,peerName:peer?.name??'',expiresAt:Math.floor(Date.now()/1000)+90});
|
||||
}
|
||||
+4
-3
@@ -1,5 +1,6 @@
|
||||
import {classicMode,classicCommand} from './classic';
|
||||
import {observeIntentChecks} from './intent-evidence';
|
||||
import type {Bot,State,StateStore,SocialEntry} from './types';
|
||||
import type {Bot,State,StateStore,SocialEntry,RuntimeConfig} from './types';
|
||||
import {luaString} from './ale';
|
||||
import {queueAleCommand} from './ale-store';
|
||||
|
||||
@@ -103,7 +104,7 @@ async function ensure(db:D1Database){
|
||||
await db.prepare('CREATE TABLE IF NOT EXISTS community_actions (id TEXT PRIMARY KEY,created_at TEXT,kind TEXT,a TEXT,b TEXT,command_id TEXT,status TEXT,reason TEXT)').run();
|
||||
await db.prepare('CREATE TABLE IF NOT EXISTS community_clock (id TEXT PRIMARY KEY,at INTEGER)').run();
|
||||
}
|
||||
export async function communityTick(store:StateStore,db:D1Database){
|
||||
export async function communityTick(store:StateStore,db:D1Database,config:RuntimeConfig={}){
|
||||
await ensure(db);
|
||||
const pending=await db.prepare("SELECT * FROM community_actions WHERE status='pending' LIMIT 20").all<any>();
|
||||
for(const action of pending.results){
|
||||
@@ -147,7 +148,7 @@ export async function communityTick(store:StateStore,db:D1Database){
|
||||
}
|
||||
}
|
||||
async function propose(kind:'party'|'leave'|'recruit',a:Bot,b:Bot|undefined,reason:string){
|
||||
const command=await queueAleCommand(db,compileCommunityAction(kind,a,b));
|
||||
const command=await queueAleCommand(db,classicMode(config)?classicCommand(kind,a,kind==='leave'?a.world!.groupId:'',b):compileCommunityAction(kind,a,b));
|
||||
await db.prepare('INSERT INTO community_actions VALUES (?,?,?,?,?,?,?,?)').bind(command.id,new Date().toISOString(),kind,a.id,b?.id??'',command.id,'pending',reason).run();
|
||||
await changeCommunity(store,s=>socialEntry(s,{id:command.id+':proposal',at:new Date().toISOString(),kind:'invitation',names:[a.name,...(b?[b.name]:[])],text:reason+' Awaiting game confirmation.',status:'pending'}));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {classicMode,classicCommand} from './classic';
|
||||
import {bondEvidence,socialEntry,communityTick} from './community';
|
||||
import { z } from 'zod';
|
||||
import { queueAleCommand } from './ale-store';
|
||||
@@ -180,7 +181,7 @@ export async function runConversationTick(store:StateStore,db:D1Database,config:
|
||||
}
|
||||
}
|
||||
await cleanupConversations(db);
|
||||
await communityTick(store,db);
|
||||
await communityTick(store,db,config);
|
||||
const request=await leaseConversation(db);
|
||||
if(!request)return {ok:true,ran:false,reason:'No player conversation waiting'};
|
||||
|
||||
@@ -202,7 +203,9 @@ export async function runConversationTick(store:StateStore,db:D1Database,config:
|
||||
reply=cleanReply(reply);
|
||||
if(!reply)throw new Error('Conversation reply was empty');
|
||||
|
||||
const command=await queueAleCommand(db,compileReply(bot.name,request.speakerName,reply,request.channel,request.socialTurn!==undefined));
|
||||
if(classicMode(config)&&request.socialTurn===undefined)throw new Error('Classic player chat delivery is not enabled yet');
|
||||
const source=classicMode(config)?classicCommand('say',bot,reply,byName(state,request.speakerName)):compileReply(bot.name,request.speakerName,reply,request.channel,request.socialTurn!==undefined);
|
||||
const command=await queueAleCommand(db,source);
|
||||
await awaitDelivery(db,request,bot.id,reply,command.id);
|
||||
await cleanupConversations(db);
|
||||
|
||||
|
||||
+1
-1
@@ -29,4 +29,4 @@ export type Observation = {zone:string; level:number; availableActions:Activity[
|
||||
export interface GameConnector {id:string; health():Promise<{ok:boolean; detail:string}>; observe(bot:Bot):Promise<Observation>; lifecycle(bot:Bot,action:'spawn'|'pause'|'resume'|'despawn'|'retire',requestId:string):Promise<void>; decide(bot:Bot,decision:Decision,requestId:string):Promise<{summary:string;zone?:string}>;}
|
||||
export interface LLMProvider {id:string; decide(bot:Bot,observation:Observation):Promise<Decision>}
|
||||
export interface StateStore {load():Promise<State>; save(state:State,expectedRevision:number):Promise<void>}
|
||||
export type RuntimeConfig = {SOAP_URL?:string; SOAP_USER?:string; SOAP_PASSWORD?:string; BRIDGE_URL?:string; BRIDGE_TOKEN?:string; ALE_BRIDGE_TOKEN?:string; ALE_OPERATOR_TOKEN?:string; ALE_RAW_EXEC_ENABLED?:string; AUTONOMY_TOKEN?:string; LLM_URL?:string; LLM_API_KEY?:string};
|
||||
export type RuntimeConfig = {GAME_BACKEND?:string;SOAP_URL?:string; SOAP_USER?:string; SOAP_PASSWORD?:string; BRIDGE_URL?:string; BRIDGE_TOKEN?:string; ALE_BRIDGE_TOKEN?:string; ALE_OPERATOR_TOKEN?:string; ALE_RAW_EXEC_ENABLED?:string; AUTONOMY_TOKEN?:string; LLM_URL?:string; LLM_API_KEY?:string};
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Classic migration staging
|
||||
|
||||
Target: CMaNGOS Classic, client 1.12.1 build 5875, with CMaNGOS Playerbots.
|
||||
This directory contains the staged adapter and migration helpers. A successful
|
||||
build is not a completed migration or permission to bypass gameplay validation.
|
||||
|
||||
`stage-data.py --root /path/to/isolated/migration` observes an existing
|
||||
`azerothai-classic-build` container, verifies the transferred MPQs against
|
||||
`client-manifest.json`, and runs the upstream extractor with two worker threads.
|
||||
The manifest maps relative MPQ paths to `size` and `sha256`. Checksums verify
|
||||
transfer integrity against the supplied client manifest, not publisher authenticity.
|
||||
The Docker builder image must be named `azerothai-classic-builder:local`; the
|
||||
installed extractor must be under `run/bin/tools`. The staging directory and
|
||||
container files belong to UID/GID 1000. Containers have a two-core/four-GiB limit.
|
||||
|
||||
Progress and failures are written to `prerequisites-status.json`. A successful
|
||||
extraction is only a prerequisite: database import, the Classic control adapter,
|
||||
the 50-character roster, account protection, and gameplay validation remain.
|
||||
The script never starts the new realm, changes the old realm, or deletes bots.
|
||||
|
||||
`install-native.py SOURCE` installs the native adapter and version-checked hooks.
|
||||
The adapter provides identity-bound snapshots, typed intentions, guarded native
|
||||
party/guild invitations, bot-to-bot speech, durable command receipts, and Zaku's
|
||||
damage floor. `AzerothAI.RestrictBotLogin=1` restricts native bot logins to an
|
||||
explicit roster of at most 50 identities. `AzerothAI.Enable=1` enables observations
|
||||
and managed login maintenance. Both settings default off in upstream builds.
|
||||
|
||||
`plan-roster.py ACTIVE_JSON OUTPUT_JSON` maps exactly 50 saved identities to
|
||||
valid Vanilla combinations without changing faction. Death Knights become
|
||||
Warriors, Alliance Shamans become Paladins, and Horde Paladins become Shamans.
|
||||
Names remain stable; all characters are created at level 1 using native creation.
|
||||
|
||||
`bootstrap.py --root ROOT` waits for successful extraction and native compilation,
|
||||
configures the isolated Classic world/database, starts a localhost-only game
|
||||
port, creates the planned characters through the native console, and validates
|
||||
all 50 identities and levels. It leaves those characters **offline**, writes
|
||||
`classic-roster-bound.json`, and records progress in `bootstrap-status.json`.
|
||||
It does not start authentication, change client configuration, or stop Wrath.
|
||||
|
||||
`prepare-state.py --root ROOT --live LIVE_REPO` prepares a separate dashboard
|
||||
database from a consistent SQLite backup. It preserves active personalities and
|
||||
labels older memories as historical, rebinds native IDs, and resets current-world
|
||||
observations, command queues, and the population plan. It refuses to overwrite
|
||||
an existing staging database. Run before starting the staging web runtime.
|
||||
|
||||
The staging app requires `GAME_BACKEND=cmangos-classic`. `relay.py --root ROOT
|
||||
--url http://127.0.0.1:8791 --env-file ROOT/app-staging/.dev.vars` transfers only
|
||||
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.
|
||||
|
||||
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
|
||||
configuration, and a verified rollback route. Human-player chat delivery is
|
||||
explicitly disabled in the staged Classic adapter until implemented and tested.
|
||||
|
||||
Migration safeguards:
|
||||
|
||||
- Preserve the verified Wrath backup and the active-50 identity export.
|
||||
- Maintain 25 Alliance and 25 Horde. Use valid Vanilla race/class combinations.
|
||||
- Start new Vanilla characters at level 1; do not directly import Wrath inventory,
|
||||
talent, spell, or quest tables. Blood Elves, Draenei, Death Knights, Alliance
|
||||
Shamans, and Horde Paladins need explicit compatible replacements.
|
||||
- Retain personalities and historical memories; reset live observations, GUIDs,
|
||||
action queues, and progress baselines when binding the new characters.
|
||||
- Protect human/GM accounts. Inactive managed profiles are cleanup candidates,
|
||||
not proof that corresponding game characters can safely be deleted.
|
||||
- Test native bot login, progress, resurrection, safety rules, read-only visitor
|
||||
access, and current observations before switching the live dashboard or client.
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resume isolated Classic startup and native level-one roster creation.
|
||||
|
||||
Never switches ports, touches Wrath databases, or starts bot login. Native
|
||||
characters remain offline until the connector and gameplay checks are ready.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
def main(root):
|
||||
credentials=json.loads((root/'database-credentials.json').read_text())
|
||||
env=dict(os.environ,MYSQL_PWD=credentials['root'])
|
||||
def mysql(sql):
|
||||
result=subprocess.run(['docker','exec','-i','-e','MYSQL_PWD','azerothai-classic-db','mysql','-uroot','-N','-B','--raw','classiccharacters'],input=sql,text=True,capture_output=True,env=env)
|
||||
if result.returncode: raise RuntimeError('Isolated Classic database operation failed: '+result.stderr.replace(credentials['root'],'[redacted]').replace(credentials['password'],'[redacted]'))
|
||||
return result.stdout.strip()
|
||||
def status(stage,**extra):
|
||||
value={'at':time.time(),'stage':stage,'cutover':False,**extra}
|
||||
temp=root/'bootstrap-status.tmp';temp.write_text(json.dumps(value,indent=2));temp.replace(root/'bootstrap-status.json')
|
||||
print(stage,flush=True)
|
||||
def inspect(name):
|
||||
result=subprocess.run(['docker','inspect','--format','{{json .State}}',name],capture_output=True,text=True)
|
||||
return json.loads(result.stdout) if result.returncode==0 else None
|
||||
def configure(name,values):
|
||||
template=root/'run/etc'/f'{name}.conf.dist'
|
||||
text=template.read_text()
|
||||
for key,value in values.items():
|
||||
line=key+' = '+str(value)
|
||||
pattern=r'^'+re.escape(key)+r'\s*=.*$'
|
||||
text=re.sub(pattern,lambda _:line,text,flags=re.M) if re.search(pattern,text,re.M) else text+'\n'+line+'\n'
|
||||
target=template.with_suffix('');target.write_text(text);target.chmod(0o600)
|
||||
try:
|
||||
status('waiting for verified extraction and native build')
|
||||
deadline=time.time()+6*3600
|
||||
while time.time()<deadline:
|
||||
extraction=inspect('azerothai-classic-extract');build=inspect('azerothai-classic-native-build')
|
||||
if extraction and build and not extraction['Running'] and not build['Running']:
|
||||
if extraction['ExitCode'] or build['ExitCode']:raise RuntimeError('Extraction or native build failed; see its log')
|
||||
break
|
||||
time.sleep(15)
|
||||
else:raise RuntimeError('Prerequisite wait exceeded six hours')
|
||||
# Require the verifier, not merely a zero process exit.
|
||||
for _ in range(12):
|
||||
stage=json.loads((root/'prerequisites-status.json').read_text())['stage']
|
||||
if stage.startswith('prerequisites extracted;'):break
|
||||
time.sleep(5)
|
||||
else:raise RuntimeError('Extraction verifier has not accepted generated data')
|
||||
for directory in ['dbc','maps','vmaps','mmaps']:
|
||||
if not any((root/'extracted'/directory).glob('*')):raise RuntimeError('Missing '+directory)
|
||||
roster=json.loads((root/'classic-roster-plan.json').read_text())
|
||||
if len(roster)!=50 or sum(b['team']==0 for b in roster)!=25 or sum(b['team']==1 for b in roster)!=25:raise RuntimeError('Invalid roster plan')
|
||||
if any(not re.fullmatch('[A-Za-z]{2,12}',b['name']) or b['level']!=1 for b in roster):raise RuntimeError('Invalid native creation parameters')
|
||||
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())
|
||||
(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'})
|
||||
fifo=root/'run/console'
|
||||
if not fifo.exists():os.mkfifo(fifo,0o600)
|
||||
state=inspect('azerothai-classic-world')
|
||||
if state is None:
|
||||
subprocess.run(['docker','run','-d','--name','azerothai-classic-world','--network','azerothai-classic','--cpus','2','--memory','3g','--user','1000:1000','-v',str(root)+':/work','-w','/work/run/bin','-p','127.0.0.1:8086:8086','azerothai-classic-builder:local','bash','-c','exec 3<>/work/run/console; exec ./mangosd -c /work/run/etc/mangosd.conf <&3'],check=True,capture_output=True)
|
||||
elif not state['Running']:subprocess.run(['docker','start','azerothai-classic-world'],check=True,capture_output=True)
|
||||
status('starting isolated Classic world; bot login disabled')
|
||||
for _ in range(120):
|
||||
state=inspect('azerothai-classic-world')
|
||||
if not state or not state['Running']:raise RuntimeError('Classic world exited during startup; inspect container log')
|
||||
log=subprocess.run(['docker','logs','--tail','60','azerothai-classic-world'],capture_output=True,text=True).stdout
|
||||
if 'SERVER STARTUP TIME' in log or 'World initialized' in log:break
|
||||
time.sleep(5)
|
||||
else:raise RuntimeError('Classic startup not confirmed within ten minutes')
|
||||
status('creating 50 level-one native characters; bot login disabled')
|
||||
with fifo.open('w') as console:
|
||||
for bot in roster:
|
||||
name=bot['name']
|
||||
if not mysql("SELECT guid FROM characters WHERE name='"+name+"'"):
|
||||
console.write(f"rndbot create name={name} race={bot['raceId']} class={bot['classId']} level=1 login=false gear=none\n");console.flush()
|
||||
for _ in range(20):
|
||||
if mysql("SELECT guid FROM characters WHERE name='"+name+"'"):break
|
||||
time.sleep(1)
|
||||
else:raise RuntimeError('Native creation not confirmed for '+name)
|
||||
bound=[]
|
||||
for bot in roster:
|
||||
row=mysql("SELECT guid,account,race,class,level,online FROM characters WHERE name='"+bot['name']+"'").split('\t')
|
||||
if len(row)!=6:raise RuntimeError('Missing character identity')
|
||||
guid,account,race,cls,level,online=map(int,row)
|
||||
if (race,cls,level,online)!=(bot['raceId'],bot['classId'],1,0):raise RuntimeError('Native identity or level validation failed: '+bot['name'])
|
||||
mysql(f"INSERT INTO azerothai_roster(guid,account_id,name,enabled,plot_armor) VALUES ({guid},{account},'{bot['name']}',0,{int(bot['name']=='Zaku')}) ON DUPLICATE KEY UPDATE account_id=VALUES(account_id),name=VALUES(name)")
|
||||
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')
|
||||
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]'))
|
||||
raise
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser();parser.add_argument('--root',type=pathlib.Path,required=True)
|
||||
main(parser.parse_args().root.resolve())
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install the version-checked Classic adapter into an isolated CMaNGOS checkout."""
|
||||
import argparse
|
||||
import pathlib
|
||||
import shutil
|
||||
|
||||
def patch(path, anchor, replacement):
|
||||
text = path.read_text()
|
||||
if replacement in text:
|
||||
return
|
||||
if text.count(anchor) != 1:
|
||||
raise RuntimeError(f"Upstream source changed: {path.name}")
|
||||
path.write_text(text.replace(anchor, replacement))
|
||||
|
||||
def install(source):
|
||||
here = pathlib.Path(__file__).resolve().parent
|
||||
for path in (here / "native").glob("AzerothAI.*"):
|
||||
shutil.copy2(path, source / "src/game/World" / path.name)
|
||||
for relative, anchor, invocation in [
|
||||
("src/game/World/World.cpp", " sMapMgr.Update(diff);", "AzerothAIUpdate(diff);"),
|
||||
("src/game/Entities/Unit.cpp", " uint32 health = victim->GetHealth();", "damage = AzerothAIBoundDamage(victim, damage);")
|
||||
]:
|
||||
path = source / relative
|
||||
include = '#ifdef ENABLE_PLAYERBOTS\n#include "World/AzerothAI.h"\n#endif\n'
|
||||
if include not in path.read_text():
|
||||
path.write_text(include + path.read_text())
|
||||
patch(path, anchor, anchor + "\n#ifdef ENABLE_PLAYERBOTS\n " + invocation + "\n#endif")
|
||||
# Upstream treats unknown gear modes as generated equipment. Explicitly
|
||||
# preserve ordinary character-creation equipment for our level-one roster.
|
||||
path = source / "src/modules/PlayerBots/playerbot/PlayerbotMgr.cpp"
|
||||
anchor = ' sRandomPlayerbotMgr.SetValue(botGuid, "create gear", 1, gear);'
|
||||
patch(path, anchor, ' if (gear != "none")\n ' + anchor)
|
||||
path = source / "src/game/Entities/CharacterHandler.cpp"
|
||||
include = '#ifdef ENABLE_PLAYERBOTS\n#include "World/AzerothAI.h"\n#endif\n'
|
||||
if include not in path.read_text():
|
||||
path.write_text(include + path.read_text())
|
||||
anchor = 'void PlayerbotHolder::AddPlayerBot(uint32 playerGuid, uint32 masterAccount)\n{'
|
||||
patch(path, anchor, anchor + '\n if (!AzerothAIAllowBotLogin(playerGuid)) return;')
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("source", type=pathlib.Path)
|
||||
install(parser.parse_args().source.resolve())
|
||||
@@ -0,0 +1,144 @@
|
||||
#include "AzerothAI.h"
|
||||
#include "Entities/Player.h"
|
||||
#include "World/World.h"
|
||||
#include "Server/WorldSession.h"
|
||||
#include "Server/DBCStores.h"
|
||||
#include "Globals/ObjectMgr.h"
|
||||
#include "Globals/SharedDefines.h"
|
||||
#include "Groups/Group.h"
|
||||
#include "Config/Config.h"
|
||||
#include "Database/DatabaseEnv.h"
|
||||
#include "playerbot/PlayerbotAI.h"
|
||||
#include "playerbot/RandomPlayerbotMgr.h"
|
||||
#include <atomic>
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
#include <ctime>
|
||||
|
||||
namespace {
|
||||
struct Resident { uint32_t guid, account; std::string name; bool armor; };
|
||||
std::map<uint32_t, Resident> roster;
|
||||
std::atomic<uint32_t> armorGuid{0}, armorAccount{0};
|
||||
std::string sql(std::string text) { CharacterDatabase.escape_string(text); return text; }
|
||||
std::string quoted(const std::string& text) {
|
||||
std::ostringstream out; out << '"';
|
||||
for (unsigned char c : text) {
|
||||
if (c == '"' || c == '\\') out << '\\' << c;
|
||||
else if (c < 32) out << "\\u" << std::hex << std::setw(4) << std::setfill('0') << unsigned(c);
|
||||
else out << c;
|
||||
}
|
||||
out << '"'; return out.str();
|
||||
}
|
||||
bool identified(Player* p) {
|
||||
if (!p || !p->IsInWorld()) return false;
|
||||
auto r = roster.find(p->GetGUIDLow());
|
||||
return r != roster.end() && p->GetName() == r->second.name && p->GetSession()->GetAccountId() == r->second.account
|
||||
&& p->GetPlayerbotAI();
|
||||
}
|
||||
bool managed(Player* p) { return identified(p) && !p->GetPlayerbotAI()->IsRealPlayer() && !p->GetPlayerbotAI()->HasRealPlayerMaster(); }
|
||||
bool safeGroup(Player* p) {
|
||||
Group* g = p->GetGroup(); if (!g) return true;
|
||||
unsigned count = 0;
|
||||
for (GroupReference* r = g->GetFirstMember(); r; r = r->next()) {
|
||||
if (!managed(r->getSource())) return false;
|
||||
++count;
|
||||
}
|
||||
return count == g->GetMembersCount();
|
||||
}
|
||||
bool free(Player* p) { return managed(p) && p->IsAlive() && !p->IsInCombat() && !p->IsTaxiFlying() && !p->IsNonMeleeSpellCasted(false); }
|
||||
bool nearby(Player* a, Player* b, float range) { return free(a) && free(b) && a->GetTeam() == b->GetTeam() && a->GetMapId() == b->GetMapId() && a->GetDistance(b) <= range; }
|
||||
void receipt(const std::string& id, bool ok, const std::string& result) {
|
||||
CharacterDatabase.PExecute("UPDATE azerothai_commands SET status='%s',result='%s' WHERE id='%s' AND status='processing'", ok ? "completed" : "failed", sql(result).c_str(), sql(id).c_str());
|
||||
}
|
||||
void commands() {
|
||||
auto q = CharacterDatabase.Query("SELECT id,guid,account_id,name,kind,argument,peer_guid,created_at,peer_account_id,peer_name,expires_at FROM azerothai_commands WHERE status='pending' ORDER BY created_at LIMIT 8");
|
||||
if (!q) return;
|
||||
do {
|
||||
Field* f=q->Fetch(); std::string id=f[0].GetString(), kind=f[4].GetString(), arg=f[5].GetString();
|
||||
// One world-thread consumer claims durably before any game effect.
|
||||
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 (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);
|
||||
if (peer->GetGroupInvite()) { WorldPacket accept(CMSG_GROUP_ACCEPT,0); peer->GetSession()->HandleGroupAcceptOpcode(accept); }
|
||||
receipt(id,p->GetGroup() && p->GetGroup()==peer->GetGroup(),"Native party invitation processed");
|
||||
} else if (kind=="recruit") {
|
||||
if (!nearby(p,peer,35) || !p->GetGuildId() || p->GetRank()!=0 || peer->GetGuildId() || peer->GetGuildIdInvited()) {receipt(id,false,"Guild participants unavailable or protected");continue;}
|
||||
WorldPacket invite(CMSG_GUILD_INVITE,32); invite << std::string(peer->GetName());p->GetSession()->HandleGuildInviteOpcode(invite);
|
||||
if(peer->GetGuildIdInvited()==p->GetGuildId()){WorldPacket accept(CMSG_GUILD_ACCEPT,0);peer->GetSession()->HandleGuildAcceptOpcode(accept);}
|
||||
receipt(id,peer->GetGuildId()==p->GetGuildId(),"Native guild invitation processed");
|
||||
} else if (kind=="leave") {
|
||||
if (!free(p) || !safeGroup(p) || !p->GetGroup() || std::to_string(p->GetGroup()->GetId())!=arg) { receipt(id,false,"Party changed or protected"); continue; }
|
||||
WorldPacket packet(CMSG_GROUP_DISBAND,0); p->GetSession()->HandleGroupDisbandOpcode(packet); receipt(id,!p->GetGroup(),"Native departure processed");
|
||||
} else if (kind=="say") {
|
||||
if (!nearby(p,peer,25) || arg.empty() || arg.size()>1000) { receipt(id,false,"Conversation participants unavailable nearby"); continue; }
|
||||
p->Say(arg,p->GetTeam()==ALLIANCE?LANG_COMMON:LANG_ORCISH); receipt(id,true,"In-game speech sent");
|
||||
} else if (kind=="intent") {
|
||||
if (!p->IsAlive() || p->IsTaxiFlying() || p->IsInCombat() || !safeGroup(p)) { receipt(id,false,"Native recovery, travel or combat takes priority"); continue; }
|
||||
PlayerbotAI* ai=p->GetPlayerbotAI(); bool accepted=false;
|
||||
if (arg=="quest" || arg=="explore") { ai->ChangeStrategy("-stay,+rpg,+grind",BotState::BOT_STATE_NON_COMBAT); ai->DoNextAction(); accepted=true; }
|
||||
else if (arg=="rest") { accepted=ai->DoSpecificAction("eat") || ai->DoSpecificAction("drink"); }
|
||||
else if (arg=="trade") { accepted=ai->DoSpecificAction("repair") || ai->DoSpecificAction("sell") || ai->DoSpecificAction("buy"); }
|
||||
else if (arg=="socialize") { p->HandleEmoteCommand(EMOTE_ONESHOT_WAVE); accepted=true; }
|
||||
receipt(id,accepted,accepted?"Intention submitted to native AI; outcome requires observation":"Native AI did not accept this intention");
|
||||
} else receipt(id,false,"Unsupported Classic command");
|
||||
} while(q->NextRow());
|
||||
}
|
||||
void snapshot(Player* p) {
|
||||
std::ostringstream out; std::string members, nearbyNames; Group* group=p->GetGroup();
|
||||
if(group) for(GroupReference* r=group->GetFirstMember();r;r=r->next()) if(Player* m=r->getSource()) { if(!members.empty())members+=","; members+=m->GetName(); }
|
||||
for(auto const& r:roster) if(r.first!=p->GetGUIDLow()) { Player* m=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,r.first)); if(managed(m)&&m->GetTeam()==p->GetTeam()&&m->GetMapId()==p->GetMapId()&&p->GetDistance(m)<=35) { if(!nearbyNames.empty())nearbyNames+=","; nearbyNames+=m->GetName(); } }
|
||||
out << "{\"isBot\":true,\"characterGuid\":" << p->GetGUIDLow() << ",\"accountId\":" << p->GetSession()->GetAccountId() << ",\"name\":" << quoted(p->GetName())
|
||||
<< ",\"level\":" << p->GetLevel() << ",\"race\":" << unsigned(p->getRace()) << ",\"class\":" << unsigned(p->getClass())
|
||||
<< ",\"team\":" << (p->GetTeam()==ALLIANCE?0:1) << ",\"mapId\":" << p->GetMapId() << ",\"zoneId\":" << p->GetZoneId()
|
||||
<< ",\"x\":" << p->GetPositionX() << ",\"y\":" << p->GetPositionY() << ",\"healthPct\":" << p->GetHealthPercent()
|
||||
<< ",\"xp\":" << p->GetUInt32Value(PLAYER_XP) << ",\"observedAt\":" << std::time(nullptr)
|
||||
<< ",\"isDead\":" << (p->IsAlive()?"false":"true") << ",\"isGhost\":" << (p->HasFlag(PLAYER_FLAGS,PLAYER_FLAGS_GHOST)?"true":"false")
|
||||
<< ",\"inCombat\":" << (p->IsInCombat()?"true":"false") << ",\"casting\":" << (p->IsNonMeleeSpellCasted(false)?"true":"false")
|
||||
<< ",\"taxi\":" << (p->IsTaxiFlying()?"true":"false") << ",\"humanControlled\":" << (managed(p)?"false":"true")
|
||||
<< ",\"groupId\":" << quoted(group?std::to_string(group->GetId()):"") << ",\"groupNames\":" << quoted(members)
|
||||
<< ",\"nearbyBots\":" << quoted(nearbyNames) << ",\"guildId\":" << p->GetGuildId() << ",\"guildRank\":" << p->GetRank()
|
||||
<< ",\"quests\":[";
|
||||
bool first=true;for(uint8 i=0;i<MAX_QUEST_LOG_SIZE;++i) { uint32 id=p->GetQuestSlotQuestId(i);if(id){if(!first)out<<',';out<<id;first=false;} }
|
||||
out << ']'; if(p->GetPowerType()==POWER_MANA && p->GetMaxPower(POWER_MANA)) out << ",\"manaPct\":" << 100.0*p->GetPower(POWER_MANA)/p->GetMaxPower(POWER_MANA);
|
||||
if(auto area=GetAreaEntryByAreaID(p->GetZoneId())) out << ",\"zoneName\":" << quoted(area->area_name[0]);
|
||||
out << '}';
|
||||
CharacterDatabase.PExecute("REPLACE INTO azerothai_snapshots (guid,at,payload) VALUES (%u,UNIX_TIMESTAMP(),'%s')",p->GetGUIDLow(),sql(out.str()).c_str());
|
||||
}
|
||||
}
|
||||
uint32_t AzerothAIBoundDamage(Unit* victim,uint32_t damage) {
|
||||
if(victim && victim->GetTypeId()==TYPEID_PLAYER && victim->GetGUIDLow()==armorGuid.load()) {
|
||||
Player* p=static_cast<Player*>(victim);
|
||||
if(p->GetSession()->GetAccountId()==armorAccount.load() && p->GetPlayerbotAI() && !p->GetPlayerbotAI()->IsRealPlayer()) return std::min(damage,p->GetHealth()>1?p->GetHealth()-1:0u);
|
||||
}
|
||||
return damage;
|
||||
}
|
||||
bool AzerothAIAllowBotLogin(uint32_t guid) {
|
||||
if(!sConfig.GetBoolDefault("AzerothAI.RestrictBotLogin",false))return true;
|
||||
auto count=CharacterDatabase.Query("SELECT COUNT(*) FROM azerothai_roster WHERE enabled=1");
|
||||
if(!count || count->Fetch()[0].GetUInt32()>50)return false;
|
||||
auto identity=CharacterDatabase.PQuery("SELECT c.guid FROM characters c JOIN azerothai_roster r ON r.guid=c.guid AND r.account_id=c.account AND r.name=c.name WHERE r.enabled=1 AND c.guid=%u",guid);
|
||||
return bool(identity);
|
||||
}
|
||||
void AzerothAIUpdate(uint32_t diff) {
|
||||
static uint32_t elapsed=0; elapsed+=diff;if(elapsed<5000)return;elapsed=0;
|
||||
if(!sConfig.GetBoolDefault("AzerothAI.Enable",false)){armorGuid=0;armorAccount=0;return;}
|
||||
static bool started=false; if(!started){if(!CharacterDatabase.DirectExecute("UPDATE azerothai_commands SET status='failed',result='Server restarted during execution; outcome unknown, command not replayed' WHERE status='processing'"))return;started=true;}
|
||||
auto q=CharacterDatabase.Query("SELECT guid,account_id,name,plot_armor FROM azerothai_roster WHERE enabled=1 ORDER BY guid LIMIT 51");
|
||||
if(!q || q->GetRowCount()>50){roster.clear();armorGuid=0;armorAccount=0;return;}
|
||||
roster.clear();armorGuid=0;armorAccount=0;
|
||||
do { Field* f=q->Fetch();Resident r{f[0].GetUInt32(),f[1].GetUInt32(),f[2].GetString(),f[3].GetBool()};if(!r.guid||!r.account)continue;roster.emplace(r.guid,r);if(r.armor && r.name=="Zaku"){armorAccount=r.account;armorGuid=r.guid;} }while(q->NextRow());
|
||||
// Population is drawn exclusively from the explicit identity allowlist.
|
||||
static std::map<uint32_t,time_t> loginAttempt;
|
||||
for(auto const& pair:roster){auto const&r=pair.second;Player* p=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,r.guid));
|
||||
if(!p){if(std::time(nullptr)-loginAttempt[r.guid]>=60){auto identity=CharacterDatabase.PQuery("SELECT guid FROM characters WHERE guid=%u AND account=%u AND name='%s'",r.guid,r.account,sql(r.name).c_str());if(identity){sRandomPlayerbotMgr.AddPlayerBot(r.guid,0);loginAttempt[r.guid]=std::time(nullptr);}}continue;}
|
||||
if(identified(p))snapshot(p);
|
||||
}
|
||||
commands();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
class Unit;
|
||||
void AzerothAIUpdate(uint32_t diff);
|
||||
uint32_t AzerothAIBoundDamage(Unit* victim, uint32_t damage);
|
||||
bool AzerothAIAllowBotLogin(uint32_t guid);
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Produce a validated level-one Vanilla roster; never modify live characters."""
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
RACES = {"Human":1,"Orc":2,"Dwarf":3,"Night Elf":4,"Undead":5,"Tauren":6,"Gnome":7,"Troll":8}
|
||||
CLASSES = {"Warrior":1,"Paladin":2,"Hunter":3,"Rogue":4,"Priest":5,"Shaman":7,"Mage":8,"Warlock":9,"Druid":11}
|
||||
ALLOWED = {1:{1,2,4,5,8,9},2:{1,3,4,7,9},3:{1,2,3,4,5},4:{1,3,4,5,11},5:{1,4,5,8,9},6:{1,3,7,11},7:{1,4,8,9},8:{1,3,4,5,7,8}}
|
||||
ALLIANCE = {"Human","Dwarf","Night Elf","Gnome","Draenei"}
|
||||
|
||||
def plan(bots):
|
||||
if len(bots) != 50 or len({b['name'].lower() for b in bots}) != 50:
|
||||
raise ValueError("Exactly 50 unique active identities required")
|
||||
result = []
|
||||
for b in bots:
|
||||
if not re.fullmatch(r'[A-Za-z]{2,12}',b['name']):
|
||||
raise ValueError("Invalid native character name")
|
||||
race, role = b['race'], b['role']
|
||||
team = 0 if race in ALLIANCE else 1
|
||||
if role == 'Death Knight': role = 'Warrior'
|
||||
if team == 0 and role == 'Shaman': role = 'Paladin'
|
||||
if team == 1 and role == 'Paladin': role = 'Shaman'
|
||||
if race == 'Draenei': race = 'Dwarf' if role in ('Hunter','Paladin','Warrior') else 'Human'
|
||||
if race == 'Blood Elf': race = 'Tauren' if role == 'Shaman' else 'Undead'
|
||||
race_id, class_id = RACES[race], CLASSES[role]
|
||||
if class_id not in ALLOWED[race_id]:
|
||||
raise ValueError(f"Invalid Vanilla combination: {b['name']} {race} {role}")
|
||||
if (race in ALLIANCE) != (team == 0): raise ValueError('Faction changed')
|
||||
result.append(dict(id=b['id'],name=b['name'],race=race,role=role,raceId=race_id,classId=class_id,team=team,level=1,
|
||||
oldRace=b['race'],oldRole=b['role'],oldLevel=b['level']))
|
||||
if collections.Counter(x['team'] for x in result) != {0:25,1:25}:
|
||||
raise ValueError('Roster must be 25 Alliance and 25 Horde')
|
||||
return result
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser=argparse.ArgumentParser()
|
||||
parser.add_argument('active',type=pathlib.Path)
|
||||
parser.add_argument('output',type=pathlib.Path)
|
||||
args=parser.parse_args()
|
||||
rows=plan(json.loads(args.active.read_text()))
|
||||
args.output.write_text(json.dumps(rows,indent=2)+'\n')
|
||||
print('Validated: 50 identities, 25 Alliance, 25 Horde, all start at level 1')
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare the isolated dashboard database after native identities are verified."""
|
||||
import argparse
|
||||
import copy
|
||||
import datetime
|
||||
import json
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
def main(root,live):
|
||||
stage=root/'app-staging'
|
||||
bound=json.loads((root/'classic-roster-bound.json').read_text())
|
||||
assert len(bound)==50 and len({b['characterGuid'] for b in bound})==50
|
||||
with urllib.request.urlopen('http://127.0.0.1:8787/api/state',timeout=10) as response:state=json.load(response)
|
||||
# This archive captures memories accumulated since the initial migration export.
|
||||
(root/'wrath-state-before-dashboard-copy.json').write_text(json.dumps(state))
|
||||
old={b['id']:b for b in state['bots']}
|
||||
now=datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
bots=[]
|
||||
for identity in bound:
|
||||
bot=copy.deepcopy(old[identity['id']])
|
||||
bot.update(name=identity['name'],race=identity['race'],role=identity['role'],level=1,zone='Awaiting Classic observation',status='offline',simulationBand='dormant',decisions=0)
|
||||
for key in ['world','lastDecision','deathMemorySeenId','lastObservation','death']:bot.pop(key,None)
|
||||
bot['autonomy']={'managed':True,'characterGuid':identity['characterGuid'],'accountId':identity['accountId']}
|
||||
for memory in bot.get('memories',[]):memory['text']='[Historical memory from the previous Wrath realm; not current Classic progress] '+memory['text']
|
||||
bot.setdefault('memories',[]).append({'id':str(uuid.uuid4()),'text':f"I am starting a new life at level 1 as a {identity['race']} {identity['role']} in Classic Azeroth. Older memories describe my previous realm.",'importance':10,'source':'observation','at':now})
|
||||
bot['memories']=bot['memories'][-100:]
|
||||
bots.append(bot)
|
||||
state['bots']=bots;state['aleRosterAt']=0;state['revision']=state.get('revision',0)+1
|
||||
state['events']=[];state.pop('operations',None);state['population']={}
|
||||
state['settings']['population']['serverMax']=50
|
||||
state['settings']['hardcore']['enabled']=False
|
||||
state['community']={'feed':[],'evidence':[],'guilds':[],'partiesEnabled':True,'guildsEnabled':True}
|
||||
ids={b['id'] for b in bots}
|
||||
for home in state.get('homes',[]):home['residentIds']=[id for id in home.get('residentIds',[]) if id in ids]
|
||||
source=list((live/'.wrangler/state/v3/d1').rglob('*.sqlite'))
|
||||
candidates=[]
|
||||
for path in source:
|
||||
with sqlite3.connect(f'file:{path}?mode=ro',uri=True) as db:
|
||||
if db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='realm_state'").fetchone():candidates.append(path)
|
||||
if len(candidates)!=1:raise RuntimeError('Expected exactly one live realm database')
|
||||
source=candidates[0];target=stage/source.relative_to(live)
|
||||
if target.exists():raise RuntimeError('Staging database already exists; refusing to overwrite')
|
||||
target.parent.mkdir(parents=True,exist_ok=True)
|
||||
with sqlite3.connect(f'file:{source}?mode=ro',uri=True) as src,sqlite3.connect(target) as dst:
|
||||
src.backup(dst)
|
||||
tables={row[0] for row in dst.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
for table in ['ale_commands','ale_events','community_actions','community_clock','conversation_requests','realm_state_chunks']:
|
||||
if table in tables:dst.execute('DELETE FROM '+table)
|
||||
text=json.dumps(state,ensure_ascii=False,separators=(',',':'))
|
||||
parts=[text[i:i+20000] for i in range(0,len(text),20000)];token=str(uuid.uuid4())
|
||||
dst.execute('CREATE TABLE IF NOT EXISTS realm_state_chunks (token TEXT,part INTEGER,payload TEXT,PRIMARY KEY(token,part))')
|
||||
dst.executemany('INSERT INTO realm_state_chunks VALUES (?,?,?)',[(token,i,p) for i,p in enumerate(parts)])
|
||||
dst.execute("UPDATE realm_state SET revision=?,payload=? WHERE id='realm'",(state['revision'],json.dumps({'chunkToken':token,'parts':len(parts)})))
|
||||
(root/'classic-initial-state.json').write_text(json.dumps(state))
|
||||
print('Isolated dashboard prepared: 50 preserved personalities, native GUIDs rebound, historical memories labeled, live observations and queues reset')
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser();parser.add_argument('--root',type=pathlib.Path,required=True);parser.add_argument('--live',type=pathlib.Path,required=True)
|
||||
args=parser.parse_args();main(args.root.resolve(),args.live.resolve())
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Relay typed Classic commands and current native observations; no Lua execution."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
RACES={1:'Human',2:'Orc',3:'Dwarf',4:'Night Elf',5:'Undead',6:'Tauren',7:'Gnome',8:'Troll'}
|
||||
CLASSES={1:'Warrior',2:'Paladin',3:'Hunter',4:'Rogue',5:'Priest',7:'Shaman',8:'Mage',9:'Warlock',11:'Druid'}
|
||||
|
||||
def validate(command):
|
||||
if not isinstance(command,dict) or command.get('version')!=1 or command.get('backend')!='cmangos-classic': raise ValueError('Not a typed Classic command')
|
||||
if command.get('kind') not in {'party','leave','recruit','intent','say'}:raise ValueError('Unsupported command kind')
|
||||
for key in ['guid','accountId','peerGuid','peerAccountId','expiresAt']:
|
||||
if type(command.get(key)) is not int or command[key]<0:raise ValueError('Invalid '+key)
|
||||
if not command['guid'] or not command['accountId'] or not re.fullmatch('[A-Za-z]{2,12}',command.get('name','')):raise ValueError('Invalid actor identity')
|
||||
if command['peerGuid'] and (not command['peerAccountId'] or not re.fullmatch('[A-Za-z]{2,12}',command.get('peerName',''))):raise ValueError('Invalid peer identity')
|
||||
if command['kind'] in {'party','recruit','say'} and not command['peerGuid']:raise ValueError('Peer identity required')
|
||||
if not isinstance(command.get('argument'),str) or len(command['argument'].encode())>1000:raise ValueError('Invalid argument')
|
||||
if command['expiresAt']<time.time() or command['expiresAt']>time.time()+120:raise ValueError('Expired or invalid deadline')
|
||||
if command['kind']=='intent' and command['argument'] not in {'quest','explore','rest','trade','socialize'}:raise ValueError('Unsupported intention')
|
||||
return command
|
||||
|
||||
def main(root,base,env_file):
|
||||
credentials=json.loads((root/'database-credentials.json').read_text())
|
||||
values={}
|
||||
for line in env_file.read_text().splitlines():
|
||||
if '=' in line and not line.lstrip().startswith('#'):
|
||||
key,value=line.split('=',1);values[key.strip()]=value.strip().strip('\"\'')
|
||||
token=values['ALE_BRIDGE_TOKEN']
|
||||
env=dict(os.environ,MYSQL_PWD=credentials['root'])
|
||||
def mysql(sql):
|
||||
result=subprocess.run(['docker','exec','-i','-e','MYSQL_PWD','azerothai-classic-db','mysql','-uroot','-N','-B','--raw','classiccharacters'],input=sql,text=True,capture_output=True,env=env,timeout=10)
|
||||
if result.returncode:raise RuntimeError('Classic database query failed')
|
||||
return [line.split('\t') for line in result.stdout.strip().splitlines()]
|
||||
def quote(text):return "CONVERT(UNHEX('"+str(text).encode().hex()+"') USING utf8mb4)"
|
||||
def request(path,payload=None):
|
||||
body=json.dumps(payload or {}).encode()
|
||||
req=urllib.request.Request(base.rstrip('/')+path,data=body,headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'})
|
||||
with urllib.request.urlopen(req,timeout=10) as response:return response.headers,response.read().decode()
|
||||
def complete(id,ok,result):request('/api/bridge/ale/result',{'id':id,'ok':ok,'result':result})
|
||||
last_snapshot=0;last_cleanup=0
|
||||
while True:
|
||||
try:
|
||||
now=int(time.time())
|
||||
if now-last_snapshot>=5:
|
||||
rows=mysql('SELECT HEX(payload) FROM azerothai_snapshots WHERE at>=UNIX_TIMESTAMP()-15 ORDER BY guid LIMIT 51')
|
||||
if len(rows)>50:raise RuntimeError('Classic snapshot population exceeds 50')
|
||||
payloads=[]
|
||||
for row in rows:
|
||||
item=json.loads(bytes.fromhex(row[0]))
|
||||
item['raceName']=RACES.get(item.get('race'),'Unknown')
|
||||
item['className']=CLASSES.get(item.get('class'),'Unknown')
|
||||
payloads.append(item)
|
||||
roster={'roster':True,'observedAt':now,'characterGuids':[p['characterGuid'] for p in payloads]}
|
||||
# Rotate eligible nearby pairs; normal app cooldowns bound conversations.
|
||||
pairs=[]
|
||||
available=lambda p:not any(p.get(k) for k in ['humanControlled','isDead','isGhost','inCombat','taxi','casting'])
|
||||
for p in payloads:
|
||||
if not available(p):continue
|
||||
for q in payloads:
|
||||
if p['characterGuid']>=q['characterGuid'] or not available(q):continue
|
||||
if p['team']==q['team'] and p['mapId']==q['mapId'] and (p['x']-q['x'])**2+(p['y']-q['y'])**2<=625:pairs.append((p,q))
|
||||
if pairs:
|
||||
a,b=pairs[(now//10)%len(pairs)];roster.update(socialA=a['name'],socialB=b['name'],zoneName=a.get('zoneName',''))
|
||||
request('/api/bridge/ale/snapshots',{'payloads':payloads+[roster]});last_snapshot=now
|
||||
headers,body=request('/api/bridge/ale/poll')
|
||||
id=headers.get('X-AzerothAI-Command')
|
||||
if id:
|
||||
if not re.fullmatch('[0-9a-fA-F-]{36}',id):raise ValueError('Invalid command ID')
|
||||
try:c=validate(json.loads(body))
|
||||
except (ValueError,TypeError) as error:complete(id,False,str(error))
|
||||
else:
|
||||
mysql(f"INSERT IGNORE INTO azerothai_commands(id,guid,account_id,name,kind,argument,peer_guid,peer_account_id,peer_name,created_at,expires_at) VALUES ({quote(id)},{c['guid']},{c['accountId']},{quote(c['name'])},{quote(c['kind'])},{quote(c['argument'])},{c['peerGuid']},{c['peerAccountId']},{quote(c['peerName'])},{c['expiresAt']-90},{c['expiresAt']})")
|
||||
for id,status,result in mysql("SELECT id,status,HEX(COALESCE(result,'')) FROM azerothai_commands WHERE delivered=0 AND status IN ('completed','failed') LIMIT 16"):
|
||||
complete(id,status=='completed',bytes.fromhex(result).decode());mysql('UPDATE azerothai_commands SET delivered=1 WHERE id='+quote(id))
|
||||
mysql("UPDATE azerothai_commands SET status='failed',result='Expired before native execution' WHERE status='pending' AND expires_at<UNIX_TIMESTAMP()")
|
||||
if now-last_cleanup>3600:
|
||||
mysql("DELETE FROM azerothai_commands WHERE delivered=1 AND created_at<UNIX_TIMESTAMP()-604800");last_cleanup=now
|
||||
except Exception as error:
|
||||
# Do not print request objects, credentials, or native SQL arguments.
|
||||
print(type(error).__name__+': '+str(error).replace(token,'[redacted]').replace(credentials['password'],'[redacted]').replace(credentials['root'],'[redacted]'),flush=True)
|
||||
time.sleep(1)
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser();parser.add_argument('--root',type=pathlib.Path,required=True);parser.add_argument('--url',required=True);parser.add_argument('--env-file',type=pathlib.Path,required=True)
|
||||
args=parser.parse_args();main(args.root,args.url,args.env_file)
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Apply only to the isolated Classic character database.
|
||||
CREATE TABLE IF NOT EXISTS azerothai_roster (
|
||||
guid INT UNSIGNED PRIMARY KEY,
|
||||
account_id INT UNSIGNED NOT NULL,
|
||||
name VARCHAR(12) NOT NULL UNIQUE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
plot_armor BOOLEAN NOT NULL DEFAULT FALSE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS azerothai_commands (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
guid INT UNSIGNED NOT NULL,
|
||||
account_id INT UNSIGNED NOT NULL,
|
||||
name VARCHAR(12) NOT NULL,
|
||||
kind VARCHAR(20) NOT NULL,
|
||||
argument TEXT NOT NULL,
|
||||
peer_guid INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
peer_account_id INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
peer_name VARCHAR(12) NOT NULL DEFAULT '',
|
||||
expires_at BIGINT UNSIGNED NOT NULL,
|
||||
created_at BIGINT UNSIGNED NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
result TEXT,
|
||||
delivered BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
INDEX pending (status,created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS azerothai_snapshots (
|
||||
guid INT UNSIGNED PRIMARY KEY,
|
||||
at BIGINT UNSIGNED NOT NULL,
|
||||
payload MEDIUMTEXT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Finish isolated Classic prerequisites. Never starts a realm or edits live data."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--root', required=True)
|
||||
args = parser.parse_args()
|
||||
root = pathlib.Path(args.root).resolve()
|
||||
|
||||
def status(stage, **extra):
|
||||
value = {'at': time.time(), 'stage': stage, 'cutover': False, **extra}
|
||||
temporary = root / 'prerequisites-status.tmp'
|
||||
temporary.write_text(json.dumps(value, indent=2))
|
||||
temporary.replace(root / 'prerequisites-status.json')
|
||||
print(stage, flush=True)
|
||||
|
||||
def run(*command):
|
||||
return subprocess.check_output(command, text=True).strip()
|
||||
|
||||
try:
|
||||
status('waiting for Classic compilation and client transfer')
|
||||
deadline = time.monotonic() + 6 * 3600
|
||||
while True:
|
||||
state = json.loads(run('docker', 'inspect', 'azerothai-classic-build'))[0]['State']
|
||||
if not state['Running']:
|
||||
if state['ExitCode'] != 0:
|
||||
raise RuntimeError('Classic build failed; inspect docker logs azerothai-classic-build')
|
||||
break
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError('Classic build exceeded six-hour observation window')
|
||||
time.sleep(30)
|
||||
manifest = json.loads((root / 'client-manifest.json').read_text())
|
||||
while not all((root / 'client' / f).exists() and (root / 'client' / f).stat().st_size == v['size'] for f, v in manifest.items()):
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError('Client transfer incomplete after six hours')
|
||||
time.sleep(30)
|
||||
status('verifying transferred client archives')
|
||||
for filename, expected in manifest.items():
|
||||
with (root / 'client' / filename).open('rb') as stream:
|
||||
actual = hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||
if actual != expected['sha256']:
|
||||
raise RuntimeError('Client archive checksum mismatch: ' + filename)
|
||||
if shutil.disk_usage(root).free < 8 * 1024**3:
|
||||
raise RuntimeError('Less than 8 GiB free for extracted Vanilla data')
|
||||
output = root / 'extracted'; output.mkdir(exist_ok=True)
|
||||
status('extracting Vanilla maps and navigation data; realm not switched')
|
||||
with (root / 'data-extraction.log').open('w') as log:
|
||||
subprocess.run([
|
||||
'docker', 'run', '--name', 'azerothai-classic-extract', '--cpus', '2', '--memory', '4g',
|
||||
'--user', '1000:1000', '-i', '-v', str(root) + ':/work', '-w', '/work/run/bin/tools',
|
||||
'azerothai-classic-builder:local', 'bash', './ExtractResources.sh', '/work/client', '/work/extracted'
|
||||
], input='y\n2\n\nn\nn\n\n', text=True, stdout=log, stderr=subprocess.STDOUT, check=True)
|
||||
counts = {name: sum(1 for f in (output / name).rglob('*') if f.is_file()) for name in ('dbc', 'maps', 'vmaps', 'mmaps')}
|
||||
if not all(counts.values()):
|
||||
raise RuntimeError('An extracted data directory is empty: ' + str(counts))
|
||||
status('prerequisites extracted; database, connector, roster and gameplay validation remain', files=counts)
|
||||
except Exception as error:
|
||||
status('blocked', error=str(error))
|
||||
raise
|
||||
@@ -3,4 +3,4 @@ import {writeFileSync,mkdirSync} from 'node:fs';
|
||||
const result=spawnSync(process.execPath,['node_modules/typescript/bin/tsc','core/state-chunks.ts','core/realm-outcomes.ts','core/realm-insights.ts','core/service.ts','core/seed.ts','core/ale.ts','core/conversation.ts','core/autonomy.ts','--types','@cloudflare/workers-types,node','--outDir','.test-build','--module','commonjs','--target','es2022','--moduleResolution','node','--esModuleInterop','--skipLibCheck','--strict'],{stdio:'inherit'});
|
||||
if(result.status)process.exit(result.status);
|
||||
mkdirSync('.test-build',{recursive:true});writeFileSync('.test-build/package.json','{"type":"commonjs"}');
|
||||
process.exit(spawnSync(process.execPath,['--test','tests/realm-outcomes.test.cjs','tests/realm-insights.test.cjs','tests/core.test.cjs','tests/hardcore.test.cjs','tests/living-world.test.cjs','tests/ale.test.cjs','tests/brains.test.cjs','tests/conversation.test.cjs','tests/autonomy.test.cjs','tests/community.test.cjs'],{stdio:'inherit'}).status??1);
|
||||
process.exit(spawnSync(process.execPath,['--test','tests/realm-outcomes.test.cjs','tests/realm-insights.test.cjs','tests/core.test.cjs','tests/classic.test.cjs','tests/hardcore.test.cjs','tests/living-world.test.cjs','tests/ale.test.cjs','tests/brains.test.cjs','tests/conversation.test.cjs','tests/autonomy.test.cjs','tests/community.test.cjs'],{stdio:'inherit'}).status??1);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const {test}=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const {classicCommand,classicMode}=require('../.test-build/classic.js');
|
||||
const actor={name:'Zaku',autonomy:{managed:true,characterGuid:7,accountId:3}};
|
||||
test('Classic transport requires explicit backend selection',()=>{
|
||||
assert.equal(classicMode({}),false);
|
||||
assert.equal(classicMode({GAME_BACKEND:'cmangos-classic'}),true);
|
||||
});
|
||||
test('Classic command rejects absent, zero and unmanaged native identities',()=>{
|
||||
for(const autonomy of [undefined,{managed:false,characterGuid:7,accountId:3},{managed:true,characterGuid:0,accountId:3},{managed:true,characterGuid:7,accountId:-1}])
|
||||
assert.throws(()=>classicCommand('intent',{...actor,autonomy},'quest'));
|
||||
assert.throws(()=>classicCommand('party',actor));
|
||||
});
|
||||
test('Classic peer commands bind both accounts and preserve literal speech',()=>{
|
||||
const peer={name:'Alany',autonomy:{managed:true,characterGuid:8,accountId:4}};
|
||||
const message='hello "friend"; return 1';
|
||||
const command=JSON.parse(classicCommand('say',actor,message,peer));
|
||||
assert.equal(command.argument,message);
|
||||
assert.equal(command.guid,7);assert.equal(command.accountId,3);
|
||||
assert.equal(command.peerGuid,8);assert.equal(command.peerAccountId,4);
|
||||
assert.ok(command.expiresAt>Date.now()/1000);
|
||||
});
|
||||
Reference in New Issue
Block a user