Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f91724ebfa | ||
|
|
3019acb9c1 | ||
|
|
0d0edc6599 | ||
|
|
6f9cdcb5a4 | ||
|
|
d7d4e899f1 | ||
|
|
f5099cd338 | ||
|
|
02d21abcbd | ||
|
|
58b2019015 |
@@ -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';
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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
+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,17 @@
|
||||
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});
|
||||
}
|
||||
|
||||
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});
|
||||
}
|
||||
+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,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,3 +1,4 @@
|
||||
import {classicMode,classicCommand,classicPlayerReply} 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,8 @@ 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));
|
||||
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 = {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};
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# 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.
|
||||
|
||||
`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:
|
||||
|
||||
- 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.
|
||||
|
||||
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
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/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())
|
||||
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.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')
|
||||
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 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','--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 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:
|
||||
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')
|
||||
# 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]'))
|
||||
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,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
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/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;')
|
||||
|
||||
# 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)
|
||||
install(parser.parse_args().source.resolve())
|
||||
@@ -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())
|
||||
@@ -0,0 +1,193 @@
|
||||
#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/PlayerbotAIConfig.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() || 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); }
|
||||
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);
|
||||
// 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());
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
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,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,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())
|
||||
@@ -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,105 @@
|
||||
#!/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','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','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')
|
||||
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;last_outcomes=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
|
||||
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:
|
||||
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_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.
|
||||
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,49 @@
|
||||
-- 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;
|
||||
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;
|
||||
@@ -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,30 @@
|
||||
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);
|
||||
});
|
||||
|
||||
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