Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f91724ebfa | ||
|
|
3019acb9c1 | ||
|
|
0d0edc6599 | ||
|
|
6f9cdcb5a4 | ||
|
|
d7d4e899f1 | ||
|
|
f5099cd338 |
@@ -1,6 +1,7 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { z } from 'zod';
|
||||
import { requireAleOperator,rawAleEnabled } from '@/core/ale-auth';
|
||||
import {runGM} from '@/core/gm';
|
||||
import { compileAleOperatorRequest } from '@/core/ale';
|
||||
import { queueAleCommand } from '@/core/ale-store';
|
||||
import type { RuntimeConfig } from '@/core/types';
|
||||
@@ -20,6 +21,11 @@ export async function POST(request:Request){
|
||||
requireAleOperator(request,config);
|
||||
const text=await request.text();if(text.length>16000)return Response.json({error:'Request too large'},{status:413});
|
||||
const body=schema.parse(JSON.parse(text));
|
||||
if(config.GAME_BACKEND==='cmangos-classic'){
|
||||
if(body.kind!=='command')throw new Error('Classic supports native GM commands here; Lua/ALE methods belong to the Wrath backend.');
|
||||
const result=await runGM(body.command,true,true,config);
|
||||
return Response.json({ok:true,result});
|
||||
}
|
||||
const source=compileAleOperatorRequest(body,rawAleEnabled(config));
|
||||
const queued=await queueAleCommand(env.DB!,source);
|
||||
return Response.json({ok:true,command:queued},{status:202});
|
||||
|
||||
@@ -26,6 +26,7 @@ const STATEFUL_EVENTS=new Set([
|
||||
'player.close-call',
|
||||
'player.killed-by-creature',
|
||||
'player.permanent-death',
|
||||
'player.death',
|
||||
'player.repop',
|
||||
'player.resurrect'
|
||||
]);
|
||||
@@ -36,6 +37,11 @@ export async function POST(request:Request){
|
||||
requireAleBridge(request,config);
|
||||
const text=await request.text();if(text.length>40000)return Response.json({error:'Event too large'},{status:413});
|
||||
const body=schema.parse(JSON.parse(text));
|
||||
const eventId=typeof body.payload.nativeEventId==='string'?body.payload.nativeEventId.slice(0,100):null;
|
||||
if(eventId){
|
||||
await env.DB!.prepare('CREATE TABLE IF NOT EXISTS native_event_receipts (id TEXT PRIMARY KEY,at INTEGER)').run();
|
||||
if(await env.DB!.prepare('SELECT id FROM native_event_receipts WHERE id=?').bind(eventId).first())return Response.json({ok:true,duplicate:true});
|
||||
}
|
||||
await recordAleEvent(env.DB!,body.kind,body.payload);
|
||||
|
||||
if((body.kind==='player.chat'||body.kind==='player.whisper')&&body.payload.isBot!==true){
|
||||
@@ -54,6 +60,10 @@ export async function POST(request:Request){
|
||||
if(zone)await execute(store,{type:'worldTrigger',kind:'zone-change',zone,playerName:String(body.payload.name??'').slice(0,40),detail:'Authoritative ALE human-player zone event'},config);
|
||||
}
|
||||
|
||||
if(eventId){
|
||||
await env.DB!.prepare('INSERT OR IGNORE INTO native_event_receipts VALUES (?,?)').bind(eventId,Math.floor(Date.now()/1000)).run();
|
||||
await env.DB!.prepare('DELETE FROM native_event_receipts WHERE at<?').bind(Math.floor(Date.now()/1000)-8*86400).run();
|
||||
}
|
||||
return Response.json({ok:true});
|
||||
}catch(e){
|
||||
const message=e instanceof Error?e.message:'ALE event failure';
|
||||
|
||||
@@ -15,7 +15,8 @@ export async function GET(){
|
||||
ok:true,
|
||||
aleActiveRecently,
|
||||
lastAleAt,
|
||||
bridgeConfigured:!!(c.BRIDGE_URL&&c.BRIDGE_TOKEN),
|
||||
gameBackend:c.GAME_BACKEND??'azerothcore',
|
||||
bridgeConfigured:c.GAME_BACKEND==='cmangos-classic'?!!c.ALE_BRIDGE_TOKEN:!!(c.BRIDGE_URL&&c.BRIDGE_TOKEN),
|
||||
aleConfigured:!!c.ALE_BRIDGE_TOKEN,
|
||||
aleOperatorConfigured:!!c.ALE_OPERATOR_TOKEN,
|
||||
aleRawEnabled:rawAleEnabled(c),
|
||||
|
||||
+7
-7
File diff suppressed because one or more lines are too long
@@ -9,3 +9,9 @@ export function classicCommand(kind:'party'|'leave'|'recruit'|'intent'|'say',bot
|
||||
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});
|
||||
}
|
||||
|
||||
export function classicPlayerReply(bot:Bot,request:{speakerGuid?:number;speakerAccountId?:number;speakerName:string;channel:'say'|'whisper'},reply:string){
|
||||
if(!Number.isInteger(request.speakerGuid)||!request.speakerGuid||request.speakerGuid<1||!Number.isInteger(request.speakerAccountId)||!request.speakerAccountId||request.speakerAccountId<1||!/^[A-Za-z]{2,12}$/.test(request.speakerName))throw new Error('Verified Classic human identity required');
|
||||
const command=JSON.parse(classicCommand('intent',bot,'socialize'));
|
||||
return JSON.stringify({...command,kind:request.channel==='whisper'?'reply-whisper':'reply-say',argument:reply,peerGuid:request.speakerGuid,peerAccountId:request.speakerAccountId,peerName:request.speakerName});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import {classicCommand} from '../classic';
|
||||
import {queueAleCommand} from '../ale-store';
|
||||
import {runGM} from '../gm';
|
||||
import type { GameConnector,Bot,Decision,RuntimeConfig } from '../types';
|
||||
|
||||
const deathSignal=z.object({
|
||||
@@ -45,5 +48,21 @@ export class AzerothCoreConnector implements GameConnector {
|
||||
async decide(bot:Bot,decision:Decision,requestId:string){return z.object({accepted:z.literal(true),summary:z.string().max(1000),zone:z.string().max(120).optional()}).parse(await this.request(`/bots/${encodeURIComponent(bot.id)}/intent`,{decision,requestId}));}
|
||||
}
|
||||
|
||||
export class ClassicConnector implements GameConnector {
|
||||
id='cmangos-classic';
|
||||
constructor(private config:RuntimeConfig){}
|
||||
async health(){const result=await runGM('server info',true,true,this.config);return {ok:true,detail:result.output};}
|
||||
async observe(bot:Bot){
|
||||
if(!bot.world||Date.now()/1000-bot.world.at>30)throw new Error('Fresh Classic observation required');
|
||||
return {zone:bot.zone,level:bot.level,alive:!bot.world.isDead&&!bot.world.isGhost,availableActions:bot.personality.activities,summary:JSON.stringify({zone:bot.zone,level:bot.level,health:bot.world.healthPct,mana:bot.world.manaPct,quests:bot.world.quests,combat:bot.world.inCombat,party:bot.world.groupNames})};
|
||||
}
|
||||
async lifecycle(){throw new Error('Classic maintains the fixed 50-character roster automatically. Manual lifecycle changes are not enabled.');}
|
||||
async decide(bot:Bot,decision:Decision){
|
||||
if(!this.config.DB)throw new Error('Classic command database is unavailable');
|
||||
const queued=await queueAleCommand(this.config.DB,classicCommand('intent',bot,decision.action));
|
||||
return {summary:`${bot.name}: requested ${decision.action}; native receipt and observed outcome are pending (${queued.id}).`,zone:bot.zone};
|
||||
}
|
||||
}
|
||||
|
||||
// Add a connector factory here; personality and memory code remains game-independent.
|
||||
export const connectors:Record<string,(config:RuntimeConfig)=>GameConnector>={simulation:()=>new SimulationConnector(),azerothcore:c=>new AzerothCoreConnector(c)};
|
||||
export const connectors:Record<string,(config:RuntimeConfig)=>GameConnector>={simulation:()=>new SimulationConnector(),azerothcore:c=>c.GAME_BACKEND==='cmangos-classic'?new ClassicConnector(c):new AzerothCoreConnector(c)};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export type ConversationChannel='say'|'whisper';
|
||||
|
||||
export type ConversationRequest={
|
||||
speakerGuid?:number;
|
||||
speakerAccountId?:number;
|
||||
socialTurn?:0|1|2|3;
|
||||
id:string;
|
||||
createdAt:string;
|
||||
@@ -16,6 +18,7 @@ export type ConversationRequest={
|
||||
};
|
||||
|
||||
async function ensure(db:D1Database){
|
||||
await db.prepare('CREATE TABLE IF NOT EXISTS conversation_identity (id TEXT PRIMARY KEY,guid INTEGER NOT NULL,account_id INTEGER NOT NULL)').run();
|
||||
await db.prepare(`CREATE TABLE IF NOT EXISTS conversation_requests (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -68,6 +71,9 @@ export async function queueConversationEvent(db:D1Database,kind:string,payload:R
|
||||
text(payload,'selectedPlayer',40),
|
||||
text(payload,'nearbyBots',1000)
|
||||
).run();
|
||||
if(Number.isInteger(payload.characterGuid)&&Number(payload.characterGuid)>0&&Number.isInteger(payload.accountId)&&Number(payload.accountId)>0){
|
||||
await db.prepare('INSERT INTO conversation_identity VALUES (?,?,?)').bind(id,payload.characterGuid,payload.accountId).run();
|
||||
}
|
||||
return {id,createdAt};
|
||||
}
|
||||
|
||||
@@ -82,6 +88,7 @@ export async function leaseConversation(db:D1Database):Promise<ConversationReque
|
||||
const row=await db.prepare(`SELECT id,created_at,speaker_name,message,channel,zone_name,receiver,selected_player,nearby_bots,status,attempts
|
||||
FROM conversation_requests WHERE status='pending' ORDER BY CASE WHEN channel LIKE 'social-%' THEN 1 ELSE 0 END,created_at LIMIT 1`).first<any>();
|
||||
if(!row)return null;
|
||||
const identity=await db.prepare('SELECT guid,account_id FROM conversation_identity WHERE id=?').bind(row.id).first<{guid:number;account_id:number}>();
|
||||
|
||||
const startedAt=new Date().toISOString();
|
||||
const updated=await db.prepare(`UPDATE conversation_requests
|
||||
@@ -90,6 +97,7 @@ export async function leaseConversation(db:D1Database):Promise<ConversationReque
|
||||
if(updated.meta.changes!==1)return null;
|
||||
|
||||
return {
|
||||
speakerGuid:identity?.guid,speakerAccountId:identity?.account_id,
|
||||
socialTurn:row.channel==='social-start'?0:row.channel==='social-reply'?1:row.channel==='social-turn-2'?2:row.channel==='social-turn-3'?3:undefined,
|
||||
id:row.id,
|
||||
createdAt:row.created_at,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {classicMode,classicCommand} from './classic';
|
||||
import {classicMode,classicCommand,classicPlayerReply} from './classic';
|
||||
import {bondEvidence,socialEntry,communityTick} from './community';
|
||||
import { z } from 'zod';
|
||||
import { queueAleCommand } from './ale-store';
|
||||
@@ -203,8 +203,7 @@ export async function runConversationTick(store:StateStore,db:D1Database,config:
|
||||
reply=cleanReply(reply);
|
||||
if(!reply)throw new Error('Conversation reply was empty');
|
||||
|
||||
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 source=classicMode(config)?(request.socialTurn===undefined?classicPlayerReply(bot,request,reply):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);
|
||||
|
||||
+3
-2
@@ -7,11 +7,12 @@ export function parseGM(raw:string){
|
||||
}
|
||||
const escapeXML=(s:string)=>s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
export async function runGM(raw:string,live:boolean,confirmed:boolean,config:RuntimeConfig){
|
||||
const command=parseGM(raw);
|
||||
const parsed=parseGM(raw);
|
||||
const command=config.GAME_BACKEND==='cmangos-classic'?parsed.replace(/^teleport name /,'tele name '):parsed;
|
||||
if(!live)return {command,output:`[SIMULATED] Accepted: ${command}\nNo command was sent to a game server.`};
|
||||
if(!confirmed)throw new Error('Confirm the live GM command before execution');
|
||||
if(!config.SOAP_URL||!config.SOAP_USER||!config.SOAP_PASSWORD)throw new Error('Configure SOAP_URL, SOAP_USER and SOAP_PASSWORD on the server first');
|
||||
const r=await fetch(config.SOAP_URL,{method:'POST',headers:{'Content-Type':'text/xml; charset=utf-8',Authorization:`Basic ${btoa(`${config.SOAP_USER}:${config.SOAP_PASSWORD}`)}`},body:`<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="urn:AC"><SOAP-ENV:Body><ns1:executeCommand><command>${escapeXML(command)}</command></ns1:executeCommand></SOAP-ENV:Body></SOAP-ENV:Envelope>`,signal:AbortSignal.timeout(12000),redirect:'error'});
|
||||
const r=await fetch(config.SOAP_URL,{method:'POST',headers:{'Content-Type':'text/xml; charset=utf-8',Authorization:`Basic ${btoa(`${config.SOAP_USER}:${config.SOAP_PASSWORD}`)}`},body:`<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="${config.GAME_BACKEND==='cmangos-classic'?'urn:MaNGOS':'urn:AC'}"><SOAP-ENV:Body><ns1:executeCommand><command>${escapeXML(command)}</command></ns1:executeCommand></SOAP-ENV:Body></SOAP-ENV:Envelope>`,signal:AbortSignal.timeout(12000),redirect:'manual'});
|
||||
const xml=await r.text();if(!r.ok||/<(?:\w+:)?Fault\b/.test(xml))throw new Error(`SOAP command failed (HTTP ${r.status}). Check the worldserver log.`);
|
||||
const output=xml.match(/<(?:\w+:)?result(?:\s[^>]*)?>([\s\S]*?)<\/(?:\w+:)?result>/)?.[1];
|
||||
if(output===undefined)throw new Error('Unexpected SOAP response; check the worldserver before retrying');
|
||||
|
||||
+6
-3
@@ -210,7 +210,7 @@ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,pa
|
||||
b.memories=b.memories.slice(-100);
|
||||
}
|
||||
event(s,'memory',`${b.name} survived a close call in ${b.zone}; the experience was committed to memory.`,b.id);
|
||||
}else if(kind==='player.killed-by-creature'||kind==='player.permanent-death'){
|
||||
}else if(kind==='player.killed-by-creature'||kind==='player.permanent-death'||kind==='player.death'){
|
||||
if(b.plotArmorHpFloor>=1){
|
||||
event(s,'error',`Plot armor breach: authoritative ALE reported ${b.name} dead. Worldserver damage enforcement must prevent this transition.`,b.id);
|
||||
}else if(s.settings.hardcore.enabled){
|
||||
@@ -236,7 +236,10 @@ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,pa
|
||||
export async function execute(store:StateStore,input:unknown,config:RuntimeConfig={}) {
|
||||
const a=actionSchema.parse(input);let s=await store.load();
|
||||
const connector=connectors[s.settings.connector](config);
|
||||
if(a.type==='health')return {health:await connector.health()};
|
||||
if(a.type==='health'){
|
||||
if(config.GAME_BACKEND==='cmangos-classic'){const result=await runGM('server info',true,true,config);return {health:{ok:true,detail:result.output}};}
|
||||
return {health:await connector.health()};
|
||||
}
|
||||
const active=Object.values(s.operations).find(o=>Date.now()-o.started<60000);
|
||||
if(active)throw new Error('A realm operation is in progress. Wait for it to finish.');
|
||||
if(Object.keys(s.operations).length){s.operations={};event(s,'error','An interrupted operation expired. Verify live server state before retrying.');}
|
||||
@@ -293,7 +296,7 @@ export async function execute(store:StateStore,input:unknown,config:RuntimeConfi
|
||||
s.recipes=old?s.recipes.map(r=>r.id===old.id?recipe:r):[...s.recipes,recipe];event(s,'recipe',`${recipe.name} saved as version ${recipe.version}. Existing bots retain their snapshot.`);
|
||||
}else if(a.type==='settings'){
|
||||
if(a.connector!==s.settings.connector&&s.bots.some(b=>b.status==='online'||b.status==='paused'))throw new Error('Despawn all online and paused bots before switching connectors.');
|
||||
if(a.connector==='azerothcore'&&(!config.BRIDGE_URL||!config.BRIDGE_TOKEN))throw new Error('AzerothCore bridge is not configured on the server.');
|
||||
if(a.connector==='azerothcore'&&config.GAME_BACKEND!=='cmangos-classic'&&(!config.BRIDGE_URL||!config.BRIDGE_TOKEN))throw new Error('AzerothCore bridge is not configured on the server.');
|
||||
if(a.provider!=='deterministic'&&!config.LLM_URL)throw new Error('Local LLM endpoint is not configured on the server.');
|
||||
s.settings={connector:a.connector,provider:a.provider,model:a.model,brains:a.brains,hardcore:a.hardcore,population:a.population};
|
||||
event(s,'system',`Realm connector and model settings updated. Hardcore one-life mode is ${a.hardcore.enabled?'enabled':'disabled'}.`);
|
||||
|
||||
+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 = {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};
|
||||
export type RuntimeConfig = {DB?:D1Database;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};
|
||||
|
||||
@@ -49,11 +49,22 @@ 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.
|
||||
`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.
|
||||
|
||||
Before cutover, verify fresh 50-character observations, quest and spell outcomes,
|
||||
normal resurrection, actual party receipts, native GM commands, authentication,
|
||||
and rollback. Human player replies require an actual in-game session for an
|
||||
end-to-end acceptance check. Lua/ALE reflection remains a Wrath-only capability.
|
||||
|
||||
Migration safeguards:
|
||||
|
||||
@@ -68,3 +79,22 @@ Migration safeguards:
|
||||
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.
|
||||
|
||||
Native outcome hooks now report quest completion/rewards, loot item counts,
|
||||
looted copper, deaths, resurrection and learned spells. These are separate from
|
||||
accepted intentions. The configured native auto-learn policy is invoked on
|
||||
managed level changes; this is not a claim of trainer purchases. Player say and
|
||||
whisper events bind the sender's native character/account identity and replies
|
||||
revalidate that identity. Basic GM commands use Classic's own SOAP namespace;
|
||||
Wrath-only Lua operator calls are rejected explicitly.
|
||||
|
||||
`prepare-auth.py --root ROOT` prepares a private Classic service GM account,
|
||||
copies only ENDAMI's existing SRP credentials into the isolated authentication
|
||||
database, and disables upstream sample logins. `check-auth.py --root ROOT`
|
||||
verifies a complete SRP handshake and realm listing without entering the game.
|
||||
`health.py --root ROOT --url URL` reports host health without Wrath SQL.
|
||||
|
||||
`cutover.py --root ROOT` requires a passing build and all 50 fresh staging bots,
|
||||
backs up stopped Wrath accounts/characters, switches the existing endpoints, and
|
||||
automatically restores Wrath endpoints if login or population verification fails.
|
||||
Miniflare metadata databases are excluded from game-state backup selection.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded daily backup of Classic persistent accounts, characters and dashboard."""
|
||||
import argparse,gzip,json,os,pathlib,shutil,sqlite3,subprocess,time
|
||||
p=argparse.ArgumentParser();p.add_argument('--root',type=pathlib.Path,required=True);a=p.parse_args();root=a.root
|
||||
base=root/'backups';base.mkdir(mode=0o700,exist_ok=True)
|
||||
target=base/time.strftime('%Y%m%dT%H%M%SZ',time.gmtime());target.mkdir(mode=0o700)
|
||||
c=json.loads((root/'database-credentials.json').read_text())
|
||||
try:
|
||||
with gzip.open(target/'classic-auth-characters.sql.gz','wb') as out:
|
||||
p=subprocess.Popen(['docker','exec','-e','MYSQL_PWD','azerothai-classic-db','mysqldump','-uroot','--single-transaction','--set-gtid-purged=OFF','--databases','classicrealmd','classiccharacters'],stdout=subprocess.PIPE,stderr=subprocess.PIPE,env=dict(os.environ,MYSQL_PWD=c['root']))
|
||||
shutil.copyfileobj(p.stdout,out)
|
||||
if p.wait():raise RuntimeError('Classic backup failed')
|
||||
dbs=[p for p in (root/'app-staging/.wrangler/state/v3/d1').rglob('*.sqlite') if p.name!='metadata.sqlite'];assert len(dbs)==1
|
||||
source=sqlite3.connect(dbs[0]);dest=sqlite3.connect(target/'dashboard.sqlite');source.backup(dest);dest.close();source.close()
|
||||
shutil.copytree(root/'run/etc',target/'config')
|
||||
(target/'complete.json').write_text(json.dumps({'at':time.time(),'backend':'cmangos-classic'}))
|
||||
for old in sorted(base.iterdir(),reverse=True)[3:]:
|
||||
if old.is_dir() and (old/'complete.json').exists():shutil.rmtree(old)
|
||||
print('Classic backup complete: '+str(target))
|
||||
except Exception:
|
||||
(target/'failed').touch();raise
|
||||
@@ -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')
|
||||
@@ -70,13 +72,16 @@ def main(root):
|
||||
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):
|
||||
for attempt in range(720):
|
||||
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
|
||||
log=subprocess.run(['docker','logs','--since',state['StartedAt'],'azerothai-classic-world'],capture_output=True,text=True).stdout
|
||||
if 'SERVER STARTUP TIME' in log or 'World initialized' in log:break
|
||||
if attempt%12==0:status('Classic first startup is building caches; bot login disabled',waitedSeconds=attempt*5)
|
||||
time.sleep(5)
|
||||
else:raise RuntimeError('Classic startup not confirmed within ten minutes')
|
||||
else:raise RuntimeError('Classic startup not confirmed within sixty minutes; inspect cache progress')
|
||||
# Restore full durability before any persistent characters are created.
|
||||
mysql('SET GLOBAL innodb_flush_log_at_trx_commit=1; SET GLOBAL sync_binlog=1;')
|
||||
status('creating 50 level-one native characters; bot login disabled')
|
||||
with fifo.open('w') as console:
|
||||
for bot in roster:
|
||||
@@ -97,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]'))
|
||||
|
||||
@@ -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,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify Classic SRP login and realm listing without entering the game world."""
|
||||
import argparse,hashlib,json,pathlib,secrets,socket,struct
|
||||
|
||||
def verify(host,port,credentials):
|
||||
username=credentials['username'].upper().encode();password=credentials['password'].upper().encode()
|
||||
def h(data):return hashlib.sha1(data).digest()
|
||||
with socket.create_connection((host,port),timeout=8) as sock:
|
||||
def read(n):
|
||||
data=b''
|
||||
while len(data)<n:
|
||||
block=sock.recv(n-len(data))
|
||||
if not block:raise RuntimeError('Authentication connection closed')
|
||||
data+=block
|
||||
return data
|
||||
payload=b'WoW\0'+bytes([1,12,1])+struct.pack('<H',5875)+b'68x\0'+b'niW\0'+b'SUne'+struct.pack('<I',0)+socket.inet_aton('127.0.0.1')+bytes([len(username)])+username
|
||||
sock.sendall(bytes([0,0])+struct.pack('<H',len(payload))+payload)
|
||||
header=read(3)
|
||||
if header!=b'\0\0\0':raise RuntimeError('Classic challenge rejected: '+header.hex())
|
||||
Braw=read(32);graw=read(read(1)[0]);Nraw=read(read(1)[0]);salt=read(32);read(16)
|
||||
flags=read(1)[0]
|
||||
if flags:raise RuntimeError('Unexpected extra authentication factor')
|
||||
N=int.from_bytes(Nraw,'little');g=int.from_bytes(graw,'little');B=int.from_bytes(Braw,'little')
|
||||
a=secrets.randbits(256);A=pow(g,a,N).to_bytes(32,'little')
|
||||
x=int.from_bytes(h(salt+h(username+b':'+password)),'little')
|
||||
u=int.from_bytes(h(A+Braw),'little')
|
||||
secret=pow((B-3*pow(g,x,N))%N,a+u*x,N).to_bytes(32,'little')
|
||||
even=h(secret[::2]);odd=h(secret[1::2]);key=bytes(v for pair in zip(even,odd) for v in pair)
|
||||
proof=h(bytes(x^y for x,y in zip(h(Nraw),h(graw)))+h(username)+salt+A+Braw+key)
|
||||
sock.sendall(b'\x01'+A+proof+bytes(20)+b'\0\0')
|
||||
result=read(2)
|
||||
if result!=b'\x01\0':raise RuntimeError('Classic password proof rejected: '+result.hex())
|
||||
if read(20)!=h(A+proof+key):raise RuntimeError('Server password proof did not match')
|
||||
read(4)
|
||||
sock.sendall(b'\x10'+bytes(4))
|
||||
if read(1)!=b'\x10':raise RuntimeError('Unexpected realm-list response')
|
||||
realms=read(struct.unpack('<H',read(2))[0])
|
||||
if b'AzerothAI Classic\0' not in realms:raise RuntimeError('Expected Classic realm missing')
|
||||
print('Classic password proof, server proof and AzerothAI Classic realm listing verified.')
|
||||
|
||||
if __name__=='__main__':
|
||||
p=argparse.ArgumentParser();p.add_argument('--root',type=pathlib.Path,required=True);p.add_argument('--host',default='127.0.0.1');p.add_argument('--port',type=int,default=3725);a=p.parse_args()
|
||||
verify(a.host,a.port,json.loads((a.root/'classic-gm-credentials.json').read_text()))
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Switch the validated Classic staging realm into the existing local endpoints.
|
||||
Preserves Wrath containers, user services, database and a final database dump.
|
||||
On an error restores the former endpoints without overwriting either database.
|
||||
"""
|
||||
import argparse,gzip,json,os,pathlib,shutil,sqlite3,subprocess,time,urllib.request
|
||||
|
||||
|
||||
def main(root):
|
||||
home=pathlib.Path.home();units=home/'.config/systemd/user';app=root/'app-staging'
|
||||
archive=root/('cutover-backup-'+str(int(time.time())));archive.mkdir(mode=0o700,exist_ok=False)
|
||||
def run(args,check=True):
|
||||
p=subprocess.run(args,capture_output=True,text=True)
|
||||
if check and p.returncode:raise RuntimeError('Command failed: '+' '.join(args[:4])+' '+p.stderr[-500:])
|
||||
return p.stdout.strip()
|
||||
def status(stage,**extra):
|
||||
value={'at':time.time(),'stage':stage,**extra};(root/'cutover-status.json').write_text(json.dumps(value,indent=2));print(stage,flush=True)
|
||||
def state():return json.load(urllib.request.urlopen('http://127.0.0.1:8791/api/state',timeout=10))
|
||||
s=state();bots=s['bots']
|
||||
assert (root/'final-build-passed').exists()
|
||||
assert len(bots)==50 and all(b['status']=='online' and time.time()-b['world']['at']<20 for b in bots)
|
||||
assert sum(b['world']['team']==0 for b in bots)==25 and sum(b['world']['team']==1 for b in bots)==25
|
||||
assert not s['settings']['hardcore']['enabled']
|
||||
(archive/'classic-state.json').write_text(json.dumps(s))
|
||||
old=['azerothai-runtime','azerothai-autonomy','azerothai-ale-relay','azerothai-operations.timer','azerothai-backup.timer']
|
||||
classic=['azerothai-classic-runtime','azerothai-classic-relay','azerothai-classic-autonomy']
|
||||
for file in units.glob('azerothai*'):shutil.copy2(file,archive/file.name)
|
||||
shutil.copy2(app/'.dev.vars',archive/'classic.dev.vars');shutil.copy2(root/'staging-worker.env',archive/'staging-worker.env')
|
||||
changed=False
|
||||
try:
|
||||
status('Stopping services for the validated Classic cutover')
|
||||
changed=True
|
||||
run(['systemctl','--user','disable','--now',*old],False)
|
||||
run(['systemctl','--user','stop','azerothai-operations.service','azerothai-backup.service','azerothai-spectator',*classic],False)
|
||||
run(['docker','stop','-t','30','ac-worldserver','ac-authserver','azerothai-dashboard-proxy'])
|
||||
run(['docker','rename','azerothai-dashboard-proxy','azerothai-dashboard-proxy-wrath-rollback'])
|
||||
status('Saving final stopped Wrath character and account databases')
|
||||
cfg=json.loads(run(['docker','inspect','ac-database']))[0]
|
||||
values=dict(v.split('=',1) for v in cfg['Config']['Env'] if '=' in v)
|
||||
password=values.get('MYSQL_ROOT_PASSWORD') or values.get('MARIADB_ROOT_PASSWORD')
|
||||
env=dict(os.environ,MYSQL_PWD=password)
|
||||
with gzip.open(archive/'wrath-auth-characters.sql.gz','wb') as out:
|
||||
proc=subprocess.Popen(['docker','exec','-e','MYSQL_PWD','ac-database','mysqldump','-uroot','--single-transaction','--set-gtid-purged=OFF','--databases','acore_auth','acore_characters'],stdout=subprocess.PIPE,stderr=subprocess.PIPE,env=env)
|
||||
shutil.copyfileobj(proc.stdout,out)
|
||||
if proc.wait():raise RuntimeError('Final Wrath database backup failed')
|
||||
assert (archive/'wrath-auth-characters.sql.gz').stat().st_size>10000
|
||||
status('Publishing Classic game and authentication ports')
|
||||
run(['docker','stop','-t','30','azerothai-classic-world','azerothai-classic-auth'])
|
||||
for name in ['azerothai-classic-world','azerothai-classic-auth']:
|
||||
run(['docker','rm','-f',name+'-staging'],False)
|
||||
run(['docker','rename',name,name+'-staging'])
|
||||
common=['--network','azerothai-classic-access','--restart','unless-stopped','--user','1000:1000','-v',str(root)+':/work','-w','/work/run/bin']
|
||||
run(['docker','create','--name','azerothai-classic-world',*common,'--cpus','2','--memory','3g','-p','8086:8086','azerothai-classic-builder:local','bash','-c','exec 3<>/work/run/console; exec ./mangosd -c /work/run/etc/mangosd.conf <&3'])
|
||||
run(['docker','create','--name','azerothai-classic-auth',*common,'--cpus','0.5','--memory','128m','-p','3724:3724','azerothai-classic-builder:local','./realmd','-c','/work/run/etc/realmd.conf'])
|
||||
for name in ['azerothai-classic-world','azerothai-classic-auth']:
|
||||
run(['docker','network','connect','azerothai-classic',name]);run(['docker','start',name])
|
||||
run(['docker','update','--restart','unless-stopped','azerothai-classic-db'])
|
||||
ip=run(['docker','inspect','--format','{{with index .NetworkSettings.Networks "azerothai-classic-access"}}{{.IPAddress}}{{end}}','azerothai-classic-world'])
|
||||
p=app/'.dev.vars';text=p.read_text();import re
|
||||
text=re.sub(r'^SOAP_URL=.*$','SOAP_URL=http://'+ip+':7879',text,flags=re.M);p.write_text(text)
|
||||
# Discard copied Wrath metrics; native Classic counters remain intact.
|
||||
databases=[p for p in (app/'.wrangler/state/v3/d1').rglob('*.sqlite') if p.name!='metadata.sqlite']
|
||||
assert len(databases)==1
|
||||
db=sqlite3.connect(databases[0]);backup=sqlite3.connect(archive/'classic-before-cutover.sqlite');db.backup(backup);backup.close()
|
||||
names=[x[0] for x in db.execute("SELECT name FROM sqlite_master WHERE type='table'")]
|
||||
for name in names:
|
||||
if name.startswith('insights_') or name=='operations_samples':db.execute('DELETE FROM "'+name+'"')
|
||||
db.commit();db.close()
|
||||
for name in classic:
|
||||
p=units/(name+'.service');text=p.read_text().replace('8791','8787').replace('RUNTIME_HOST=127.0.0.1','RUNTIME_HOST=0.0.0.0');p.write_text(text)
|
||||
p=root/'staging-worker.env';p.write_text(p.read_text().replace('8791','8787'))
|
||||
p=units/'azerothai-spectator.service';p.write_text(p.read_text().replace('/home/endami/AzerothAI',str(app)).replace('After=network-online.target azerothai-runtime.service','After=network-online.target azerothai-classic-runtime.service'))
|
||||
run(['systemctl','--user','daemon-reload']);run(['systemctl','--user','enable','--now',*classic,'azerothai-spectator'])
|
||||
run(['docker','run','-d','--name','azerothai-dashboard-proxy','--restart','unless-stopped','--network','azerothai-mini','--add-host','host.docker.internal:host-gateway','--memory','128m','-p','8788:80','-v',str(home/'.config/azerothai/nginx.conf')+':/etc/nginx/conf.d/default.conf:ro','-v',str(app/'dist/client')+':/srv:ro','nginx:alpine'])
|
||||
status('Classic endpoints active; waiting for all 50 fresh observations')
|
||||
for _ in range(36):
|
||||
try:
|
||||
current=json.load(urllib.request.urlopen('http://127.0.0.1:8787/api/state',timeout=5));bs=current['bots']
|
||||
if len(bs)==50 and all(b['status']=='online' and time.time()-b['world']['at']<20 for b in bs):break
|
||||
except Exception:pass
|
||||
time.sleep(5)
|
||||
else:raise RuntimeError('Classic did not restore all 50 fresh observations')
|
||||
assert sum(b['world']['team']==0 for b in bs)==25 and sum(b['world']['team']==1 for b in bs)==25
|
||||
run(['python3',str(root/'adapter/check-auth.py'),'--root',str(root),'--port','3724'])
|
||||
status('Classic cutover complete; 50 fresh bots, 25 Alliance and 25 Horde; login verified',cutover=True,backup=str(archive))
|
||||
except Exception as error:
|
||||
if changed:
|
||||
run(['systemctl','--user','stop',*classic,'azerothai-spectator'],False)
|
||||
run(['docker','stop','azerothai-classic-world','azerothai-classic-auth'],False)
|
||||
if run(['docker','ps','-a','--filter','name=^azerothai-dashboard-proxy-wrath-rollback$','--format','{{.Names}}'],False):
|
||||
run(['docker','rm','-f','azerothai-dashboard-proxy'],False);run(['docker','rename','azerothai-dashboard-proxy-wrath-rollback','azerothai-dashboard-proxy'],False)
|
||||
for file in archive.glob('azerothai*'):shutil.copy2(file,units/file.name)
|
||||
run(['systemctl','--user','daemon-reload'],False)
|
||||
run(['docker','start','ac-worldserver','ac-authserver','azerothai-dashboard-proxy'],False)
|
||||
run(['systemctl','--user','enable','--now',*old,'azerothai-spectator'],False)
|
||||
status('Classic cutover blocked; former Wrath endpoints restored',error=str(error),cutover=False)
|
||||
raise
|
||||
|
||||
if __name__=='__main__':
|
||||
p=argparse.ArgumentParser();p.add_argument('--root',type=pathlib.Path,required=True);main(p.parse_args().root.resolve())
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Classic host readings only; never runs Wrath maintenance SQL."""
|
||||
import argparse,json,pathlib,shutil,subprocess,time,urllib.request
|
||||
p=argparse.ArgumentParser();p.add_argument('--root',type=pathlib.Path,required=True);p.add_argument('--url',default='http://127.0.0.1:8787');a=p.parse_args()
|
||||
v={}
|
||||
for line in (a.root/'app-staging/.dev.vars').read_text().splitlines():
|
||||
if '=' in line:
|
||||
k,value=line.split('=',1);v[k]=value.strip().strip('"\'')
|
||||
def cpu():
|
||||
values=list(map(int,pathlib.Path('/proc/stat').read_text().splitlines()[0].split()[1:]));return sum(values[:8]),sum(values[3:5])
|
||||
x=cpu();time.sleep(1);y=cpu()
|
||||
m={l.split(':')[0]:int(l.split()[1])/1024 for l in pathlib.Path('/proc/meminfo').read_text().splitlines()}
|
||||
model=False
|
||||
try:
|
||||
with urllib.request.urlopen(v['LLM_URL'].rstrip('/')+'/api/tags',timeout=5) as response:model=response.status==200
|
||||
except Exception:pass
|
||||
body=dict(host='ubuntu',at=time.time(),cpuPercent=max(0,min(100,100*(1-(y[1]-x[1])/max(1,y[0]-x[0])))),memoryTotalMB=m['MemTotal'],memoryAvailableMB=m['MemAvailable'],diskFreeGB=shutil.disk_usage(a.root).free/1024**3,modelAvailable=model,runtimeService=subprocess.check_output(['systemctl','--user','is-active','azerothai-classic-runtime'],text=True).strip(),maintenance='Classic telemetry active; Wrath maintenance disabled')
|
||||
req=urllib.request.Request(a.url+'/api/operations',data=json.dumps(body).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+v['AUTONOMY_TOKEN']})
|
||||
with urllib.request.urlopen(req,timeout=15) as response:assert response.status==200
|
||||
@@ -37,6 +37,36 @@ def install(source):
|
||||
anchor = 'void PlayerbotHolder::AddPlayerBot(uint32 playerGuid, uint32 masterAccount)\n{'
|
||||
patch(path, anchor, anchor + '\n if (!AzerothAIAllowBotLogin(playerGuid)) return;')
|
||||
|
||||
# Hooks record successful native outcomes, not inferred command success.
|
||||
player = source / "src/game/Entities/Player.cpp"
|
||||
loot = source / "src/game/Loot/LootMgr.cpp"
|
||||
for path in [player, loot]:
|
||||
if include not in path.read_text():
|
||||
path.write_text(include + path.read_text())
|
||||
hooks = [
|
||||
(player, " // resend quests status directly\n UpdateForQuestWorldObjects();", 'AzerothAIEvent(this,"player.quest.reward",quest_id);'),
|
||||
(player, "void Player::CompleteQuest(uint32 quest_id)\n{\n if (quest_id)\n {\n SetQuestStatus(quest_id, QUEST_STATUS_COMPLETE);", 'AzerothAIEvent(this,"player.quest.complete",quest_id);'),
|
||||
(player, " SetDeathState(CORPSE);", 'AzerothAIEvent(this,"player.death");'),
|
||||
(loot, " plr->ModifyMoney(money_per_player);", 'AzerothAIEvent(plr,"player.loot.money",0,money_per_player);'),
|
||||
(loot, " player->ModifyMoney(m_gold);", 'AzerothAIEvent(player,"player.loot.money",0,m_gold);'),
|
||||
]
|
||||
for path, anchor, call in hooks:
|
||||
if call:
|
||||
patch(path, anchor, anchor + "\n#ifdef ENABLE_PLAYERBOTS\n " + call + "\n#endif")
|
||||
for anchor, call in [
|
||||
(" if (!applySickness)\n return;", 'AzerothAIEvent(this,"player.resurrect");'),
|
||||
(" WorldPacket data(SMSG_LEARNED_SPELL, 4);", 'AzerothAIEvent(this,"player.spell.learn",spell_id);'),
|
||||
(" WorldPacket data(SMSG_ITEM_PUSH_RESULT, (8 + 4 + 4 + 4 + 1 + 4 + 4 + 4 + 4 + 4));", 'if (!received && !created) AzerothAIEvent(this,"player.loot.item",item->GetEntry(),count);'),
|
||||
]:
|
||||
patch(player, anchor, "#ifdef ENABLE_PLAYERBOTS\n " + call + "\n#endif\n" + anchor)
|
||||
|
||||
chat=source / "src/game/Chat/ChatHandler.cpp"
|
||||
if include not in chat.read_text():chat.write_text(include+chat.read_text())
|
||||
for anchor,call in [
|
||||
(' GetPlayer()->Say(msg, lang);', 'if(lang!=LANG_ADDON && !m_anticheat->IsSilenced()) AzerothAIChat(GetPlayer(),msg);'),
|
||||
(' if (lang != LANG_ADDON && !m_anticheat->IsSilenced())\n m_anticheat->Whisper(msg, player->GetObjectGuid());', 'if(lang!=LANG_ADDON && !m_anticheat->IsSilenced()) AzerothAIChat(GetPlayer(),msg,player);')
|
||||
]:patch(chat,anchor,anchor+'\n#ifdef ENABLE_PLAYERBOTS\n '+call+'\n#endif')
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("source", type=pathlib.Path)
|
||||
|
||||
@@ -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())
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "Config/Config.h"
|
||||
#include "Database/DatabaseEnv.h"
|
||||
#include "playerbot/PlayerbotAI.h"
|
||||
#include "playerbot/PlayerbotAIConfig.h"
|
||||
#include "playerbot/RandomPlayerbotMgr.h"
|
||||
#include <atomic>
|
||||
#include <algorithm>
|
||||
@@ -61,9 +62,16 @@ 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 (kind=="party") {
|
||||
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; }
|
||||
bool humanReply=kind=="reply-say" || kind=="reply-whisper";
|
||||
bool peerAllowed=humanReply ? peer && peer->IsInWorld() && (!peer->GetPlayerbotAI() || peer->GetPlayerbotAI()->IsRealPlayer()) : managed(peer);
|
||||
if (f[6].GetUInt32() && (!peerAllowed || peer->GetSession()->GetAccountId()!=f[8].GetUInt32() || std::string(peer->GetName())!=f[9].GetString())) {receipt(id,false,"Peer identity changed or protected");continue;}
|
||||
if (humanReply) {
|
||||
if (arg.empty() || arg.size()>1000 || !peer || p->GetTeam()!=peer->GetTeam() || (kind=="reply-say" && (p->GetMapId()!=peer->GetMapId() || p->GetDistance(peer)>25))) {receipt(id,false,"Player conversation no longer available");continue;}
|
||||
if(kind=="reply-whisper")p->Whisper(arg,LANG_UNIVERSAL,peer->GetObjectGuid());
|
||||
else p->Say(arg,p->GetTeam()==ALLIANCE?LANG_COMMON:LANG_ORCISH);
|
||||
receipt(id,true,"In-game player reply sent");
|
||||
} else 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); }
|
||||
@@ -139,6 +147,47 @@ void AzerothAIUpdate(uint32_t diff) {
|
||||
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);
|
||||
// Honor the configured native auto-learn policy even for externally
|
||||
// maintained bot sessions. Do not grant spells through a second path.
|
||||
static std::map<uint32_t,uint32_t> trainedLevel;
|
||||
if(sPlayerbotAIConfig.autoLearnTrainerSpells && free(p) && trainedLevel[r.guid]!=p->GetLevel()){
|
||||
if(p->GetPlayerbotAI()->DoSpecificAction("auto learn spell"))trainedLevel[r.guid]=p->GetLevel();
|
||||
}
|
||||
}
|
||||
commands();
|
||||
}
|
||||
|
||||
void AzerothAIEvent(Player* p, const char* kind, uint32_t value, uint32_t amount) {
|
||||
if (!sConfig.GetBoolDefault("AzerothAI.Enable",false) || !managed(p)) return;
|
||||
std::string eventKind(kind), column;
|
||||
if(eventKind=="player.quest.complete")column="quests";
|
||||
else if(eventKind=="player.quest.reward")column="rewarded";
|
||||
else if(eventKind=="player.loot.item")column="lootItems";
|
||||
else if(eventKind=="player.loot.money")column="lootedCopper";
|
||||
else if(eventKind=="player.death")column="nativeDeaths";
|
||||
else if(eventKind=="player.spell.learn")column="learnedSpells";
|
||||
else if(eventKind=="player.resurrect")column="resurrections";
|
||||
if(!column.empty())CharacterDatabase.PExecute("INSERT INTO azerothai_counters(guid,%s) VALUES(%u,%u) ON DUPLICATE KEY UPDATE %s=%s+%u",column.c_str(),p->GetGUIDLow(),amount,column.c_str(),column.c_str(),amount);
|
||||
if(eventKind=="player.loot.item")CharacterDatabase.PExecute("INSERT INTO azerothai_counters(guid,lootEvents) VALUES(%u,1) ON DUPLICATE KEY UPDATE lootEvents=lootEvents+1",p->GetGUIDLow());
|
||||
std::ostringstream out;
|
||||
out << "{\"isBot\":true,\"characterGuid\":" << p->GetGUIDLow() << ",\"accountId\":" << p->GetSession()->GetAccountId()
|
||||
<< ",\"name\":" << quoted(p->GetName()) << ",\"level\":" << p->GetLevel() << ",\"observedAt\":" << std::time(nullptr)
|
||||
<< ",\"value\":" << value << ",\"amount\":" << amount;
|
||||
if(auto area=GetAreaEntryByAreaID(p->GetZoneId()))out << ",\"zoneName\":" << quoted(area->area_name[0]);
|
||||
out << '}';
|
||||
CharacterDatabase.PExecute("INSERT INTO azerothai_events(at,kind,payload) VALUES(UNIX_TIMESTAMP(),'%s','%s')",sql(eventKind).c_str(),sql(out.str()).c_str());
|
||||
}
|
||||
|
||||
void AzerothAIChat(Player* p, const std::string& message, Player* receiver) {
|
||||
if(!sConfig.GetBoolDefault("AzerothAI.Enable",false) || !p || !p->IsInWorld() || (p->GetPlayerbotAI() && !p->GetPlayerbotAI()->IsRealPlayer()) || message.empty() || message.size()>500 || message[0]=='.' || message[0]=='!')return;
|
||||
if(receiver && !managed(receiver))return;
|
||||
std::string names;
|
||||
for(auto const& r:roster){Player* bot=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,r.first));if(managed(bot) && bot->GetTeam()==p->GetTeam() && bot->GetMapId()==p->GetMapId() && bot->GetDistance(p)<=25){if(!names.empty())names+=",";names+=bot->GetName();}}
|
||||
if(!receiver && names.empty())return;
|
||||
std::ostringstream out;
|
||||
out << "{\"isBot\":false,\"characterGuid\":" << p->GetGUIDLow() << ",\"accountId\":" << p->GetSession()->GetAccountId() << ",\"name\":" << quoted(p->GetName())
|
||||
<< ",\"message\":" << quoted(message) << ",\"nearbyBots\":" << quoted(names) << ",\"receiver\":" << quoted(receiver?receiver->GetName():"") << ",\"receiverIsBot\":" << (receiver?"true":"false");
|
||||
if(auto area=GetAreaEntryByAreaID(p->GetZoneId()))out << ",\"zoneName\":" << quoted(area->area_name[0]);
|
||||
out << '}';
|
||||
CharacterDatabase.PExecute("INSERT INTO azerothai_events(at,kind,payload) VALUES(UNIX_TIMESTAMP(),'%s','%s')",receiver?"player.whisper":"player.chat",sql(out.str()).c_str());
|
||||
}
|
||||
|
||||
@@ -4,3 +4,8 @@ class Unit;
|
||||
void AzerothAIUpdate(uint32_t diff);
|
||||
uint32_t AzerothAIBoundDamage(Unit* victim, uint32_t damage);
|
||||
bool AzerothAIAllowBotLogin(uint32_t guid);
|
||||
class Player;
|
||||
void AzerothAIEvent(Player* player, const char* kind, uint32_t value = 0, uint32_t amount = 1);
|
||||
|
||||
#include <string>
|
||||
void AzerothAIChat(Player* player, const std::string& message, Player* receiver = nullptr);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare isolated Classic authentication, preserving ENDAMI's SRP credentials.
|
||||
Does not stop Wrath or alter any Wrath account. Secrets are never printed.
|
||||
"""
|
||||
import argparse,base64,hashlib,json,os,pathlib,re,secrets,subprocess
|
||||
|
||||
def main(root):
|
||||
credentials=json.loads((root/'database-credentials.json').read_text())
|
||||
def query(container,password,sql):
|
||||
result=subprocess.run(['docker','exec','-i','-e','MYSQL_PWD',container,'mysql','-uroot','-N','-B'],input=sql,text=True,capture_output=True,env=dict(os.environ,MYSQL_PWD=password))
|
||||
if result.returncode:raise RuntimeError('Authentication database operation failed')
|
||||
return result.stdout.strip()
|
||||
def classic(sql):return query('azerothai-classic-db',credentials['root'],sql)
|
||||
account_file=root/'classic-gm-credentials.json'
|
||||
if account_file.exists():gm=json.loads(account_file.read_text())
|
||||
else:
|
||||
gm={'username':'AZAIADMIN','password':secrets.token_hex(8)}
|
||||
account_file.write_text(json.dumps(gm));account_file.chmod(0o600)
|
||||
if gm['username'].startswith('AZCLASSIC'):
|
||||
gm['username']='AZAIADMIN'
|
||||
account_file.write_text(json.dumps(gm));account_file.chmod(0o600)
|
||||
salt=secrets.token_bytes(32)
|
||||
digest=hashlib.sha1((gm['username'].upper()+':'+gm['password'].upper()).encode()).digest()
|
||||
exponent=int.from_bytes(hashlib.sha1(salt+digest).digest(),'little')
|
||||
verifier=pow(7,exponent,int('894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7',16))
|
||||
assert re.fullmatch('[A-Z0-9]+',gm['username'])
|
||||
classic(f"INSERT INTO classicrealmd.account(username,s,v,gmlevel) VALUES ('{gm['username']}','{salt[::-1].hex()}','{verifier:X}',3) ON DUPLICATE KEY UPDATE gmlevel=3,s=VALUES(s),v=VALUES(v);")
|
||||
# Disable upstream sample logins without deleting accounts or characters.
|
||||
classic("UPDATE classicrealmd.account SET locked=1,lockedIp='disabled' WHERE id IN (1,2,3,4) AND username IN ('ADMINISTRATOR','GAMEMASTER','MODERATOR','PLAYER');")
|
||||
config=json.loads(subprocess.check_output(['docker','inspect','ac-database']))[0]
|
||||
env=dict(v.split('=',1) for v in config['Config']['Env'] if '=' in v)
|
||||
password=env.get('MYSQL_ROOT_PASSWORD') or env.get('MARIADB_ROOT_PASSWORD')
|
||||
row=query('ac-database',password,"SELECT a.username,HEX(a.salt),HEX(a.verifier),MAX(x.gmlevel) FROM acore_auth.account a JOIN acore_auth.account_access x ON x.id=a.id WHERE a.username='ENDAMI' GROUP BY a.id;").split('\t')
|
||||
if len(row)!=4 or row[0]!='ENDAMI':raise RuntimeError('Expected existing human GM account was not found')
|
||||
salt=bytes.fromhex(row[1])[::-1].hex();verifier=bytes.fromhex(row[2])[::-1].hex()
|
||||
classic(f"INSERT INTO classicrealmd.account(username,s,v,gmlevel) VALUES ('ENDAMI','{salt}','{verifier}',3) ON DUPLICATE KEY UPDATE s=VALUES(s),v=VALUES(v),gmlevel=3;")
|
||||
classic("UPDATE classicrealmd.realmlist SET name='AzerothAI Classic',address='10.0.0.125',port=8086,realmflags=0,allowedSecurityLevel=0 WHERE id=1;")
|
||||
text=(root/'run/etc/realmd.conf.dist').read_text()
|
||||
values={'LoginDatabaseInfo':'"azerothai-classic-db;3306;'+credentials['user']+';'+credentials['password']+';classicrealmd"','RealmServerPort':'3724','BindIP':'"0.0.0.0"','LogsDir':'"/work/run/logs"'}
|
||||
for key,value in values.items():
|
||||
pattern=r'^'+re.escape(key)+r'\s*=.*$';line=key+' = '+value
|
||||
text=re.sub(pattern,lambda _:line,text,flags=re.M) if re.search(pattern,text,re.M) else text+'\n'+line+'\n'
|
||||
path=root/'run/etc/realmd.conf';path.write_text(text);path.chmod(0o600)
|
||||
print('Classic GM credentials prepared; ENDAMI password verifier preserved; sample logins disabled.')
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser();parser.add_argument('--root',type=pathlib.Path,required=True)
|
||||
main(parser.parse_args().root.resolve())
|
||||
+17
-3
@@ -14,12 +14,12 @@ CLASSES={1:'Warrior',2:'Paladin',3:'Hunter',4:'Rogue',5:'Priest',7:'Shaman',8:'M
|
||||
|
||||
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')
|
||||
if command.get('kind') not in {'party','leave','recruit','intent','say','reply-say','reply-whisper'}: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 command['kind'] in {'party','recruit','say','reply-say','reply-whisper'} 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')
|
||||
@@ -43,7 +43,7 @@ def main(root,base,env_file):
|
||||
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
|
||||
last_snapshot=0;last_cleanup=0;last_outcomes=0
|
||||
while True:
|
||||
try:
|
||||
now=int(time.time())
|
||||
@@ -68,6 +68,19 @@ def main(root,base,env_file):
|
||||
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
|
||||
for event_id,kind,payload in mysql("SELECT id,kind,HEX(payload) FROM azerothai_events WHERE delivered=0 ORDER BY id LIMIT 30"):
|
||||
data=json.loads(bytes.fromhex(payload));data['nativeEventId']='classic:'+event_id
|
||||
request('/api/bridge/ale/event',{'kind':kind,'payload':data})
|
||||
mysql('UPDATE azerothai_events SET delivered=1 WHERE id='+str(int(event_id)))
|
||||
if now-last_outcomes>=60:
|
||||
rows=mysql("SELECT r.guid,r.name,COALESCE(c.quests,0),COALESCE(c.rewarded,0),COALESCE(c.lootEvents,0),COALESCE(c.lootItems,0),COALESCE(c.lootedCopper,0),COALESCE(c.nativeDeaths,0) FROM azerothai_roster r LEFT JOIN azerothai_counters c ON c.guid=r.guid WHERE r.enabled=1")
|
||||
outcomes=[]
|
||||
for guid,name,quests,rewarded,events,items,copper,deaths in rows:
|
||||
outcomes.append(dict(guid=int(guid),name=name,quests=int(quests),rewarded=int(rewarded),lootEvents=int(events),lootItems=int(items),lootedCopper=int(copper),nativeDeaths=int(deaths),abandoned=0,partyKills=0))
|
||||
body=json.dumps(dict(at=now,recoveries=[],outcomes=outcomes)).encode()
|
||||
req=urllib.request.Request(base.rstrip('/')+'/api/realm-insights',data=body,headers={'Authorization':'Bearer '+values['AUTONOMY_TOKEN'],'Content-Type':'application/json'})
|
||||
with urllib.request.urlopen(req,timeout=10) as response:response.read()
|
||||
last_outcomes=now
|
||||
headers,body=request('/api/bridge/ale/poll')
|
||||
id=headers.get('X-AzerothAI-Command')
|
||||
if id:
|
||||
@@ -80,6 +93,7 @@ def main(root,base,env_file):
|
||||
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_events WHERE delivered=1 AND at<UNIX_TIMESTAMP()-604800")
|
||||
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.
|
||||
|
||||
@@ -28,3 +28,22 @@ CREATE TABLE IF NOT EXISTS azerothai_snapshots (
|
||||
at BIGINT UNSIGNED NOT NULL,
|
||||
payload MEDIUMTEXT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS azerothai_events (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
at BIGINT UNSIGNED NOT NULL,
|
||||
kind VARCHAR(100) NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
delivered BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
INDEX delivery (delivered,id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS azerothai_counters (
|
||||
guid INT UNSIGNED PRIMARY KEY,
|
||||
quests BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
rewarded BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
lootEvents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
lootItems BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
lootedCopper BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
nativeDeaths BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
learnedSpells BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
resurrections BIGINT UNSIGNED NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
@@ -20,3 +20,11 @@ test('Classic peer commands bind both accounts and preserve literal speech',()=>
|
||||
assert.equal(command.peerGuid,8);assert.equal(command.peerAccountId,4);
|
||||
assert.ok(command.expiresAt>Date.now()/1000);
|
||||
});
|
||||
|
||||
test('Classic human replies require authoritative recipient identity',()=>{
|
||||
const {classicPlayerReply}=require('../.test-build/classic.js');
|
||||
for(const request of [{speakerName:'Endami',channel:'whisper'},{speakerName:'Endami',channel:'say',speakerGuid:-1,speakerAccountId:1}])assert.throws(()=>classicPlayerReply(actor,request,'Hello'));
|
||||
const reply=JSON.parse(classicPlayerReply(actor,{speakerName:'Endami',channel:'whisper',speakerGuid:51,speakerAccountId:11},'Hello'));
|
||||
assert.equal(reply.kind,'reply-whisper');assert.equal(reply.peerGuid,51);assert.equal(reply.peerAccountId,11);assert.equal(reply.peerName,'Endami');
|
||||
assert.equal(reply.guid,7);assert.equal(reply.argument,'Hello');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user