Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f91724ebfa | ||
|
|
3019acb9c1 | ||
|
|
0d0edc6599 | ||
|
|
6f9cdcb5a4 | ||
|
|
d7d4e899f1 | ||
|
|
f5099cd338 |
@@ -1,6 +1,7 @@
|
|||||||
import { env } from 'cloudflare:workers';
|
import { env } from 'cloudflare:workers';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { requireAleOperator,rawAleEnabled } from '@/core/ale-auth';
|
import { requireAleOperator,rawAleEnabled } from '@/core/ale-auth';
|
||||||
|
import {runGM} from '@/core/gm';
|
||||||
import { compileAleOperatorRequest } from '@/core/ale';
|
import { compileAleOperatorRequest } from '@/core/ale';
|
||||||
import { queueAleCommand } from '@/core/ale-store';
|
import { queueAleCommand } from '@/core/ale-store';
|
||||||
import type { RuntimeConfig } from '@/core/types';
|
import type { RuntimeConfig } from '@/core/types';
|
||||||
@@ -20,6 +21,11 @@ export async function POST(request:Request){
|
|||||||
requireAleOperator(request,config);
|
requireAleOperator(request,config);
|
||||||
const text=await request.text();if(text.length>16000)return Response.json({error:'Request too large'},{status:413});
|
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));
|
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 source=compileAleOperatorRequest(body,rawAleEnabled(config));
|
||||||
const queued=await queueAleCommand(env.DB!,source);
|
const queued=await queueAleCommand(env.DB!,source);
|
||||||
return Response.json({ok:true,command:queued},{status:202});
|
return Response.json({ok:true,command:queued},{status:202});
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const STATEFUL_EVENTS=new Set([
|
|||||||
'player.close-call',
|
'player.close-call',
|
||||||
'player.killed-by-creature',
|
'player.killed-by-creature',
|
||||||
'player.permanent-death',
|
'player.permanent-death',
|
||||||
|
'player.death',
|
||||||
'player.repop',
|
'player.repop',
|
||||||
'player.resurrect'
|
'player.resurrect'
|
||||||
]);
|
]);
|
||||||
@@ -36,6 +37,11 @@ export async function POST(request:Request){
|
|||||||
requireAleBridge(request,config);
|
requireAleBridge(request,config);
|
||||||
const text=await request.text();if(text.length>40000)return Response.json({error:'Event too large'},{status:413});
|
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 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);
|
await recordAleEvent(env.DB!,body.kind,body.payload);
|
||||||
|
|
||||||
if((body.kind==='player.chat'||body.kind==='player.whisper')&&body.payload.isBot!==true){
|
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(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});
|
return Response.json({ok:true});
|
||||||
}catch(e){
|
}catch(e){
|
||||||
const message=e instanceof Error?e.message:'ALE event failure';
|
const message=e instanceof Error?e.message:'ALE event failure';
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ export async function GET(){
|
|||||||
ok:true,
|
ok:true,
|
||||||
aleActiveRecently,
|
aleActiveRecently,
|
||||||
lastAleAt,
|
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,
|
aleConfigured:!!c.ALE_BRIDGE_TOKEN,
|
||||||
aleOperatorConfigured:!!c.ALE_OPERATOR_TOKEN,
|
aleOperatorConfigured:!!c.ALE_OPERATOR_TOKEN,
|
||||||
aleRawEnabled:rawAleEnabled(c),
|
aleRawEnabled:rawAleEnabled(c),
|
||||||
|
|||||||
+7
-7
File diff suppressed because one or more lines are too long
@@ -9,3 +9,9 @@ export function classicCommand(kind:'party'|'leave'|'recruit'|'intent'|'say',bot
|
|||||||
if(['party','recruit','say'].includes(kind)&&(!peer?.autonomy?.managed||!peer.autonomy.characterGuid||!peer.autonomy.accountId))throw new Error('Verified Classic peer required');
|
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});
|
return JSON.stringify({version:1,backend:'cmangos-classic',kind,guid:identity.characterGuid,accountId:identity.accountId,name:bot.name,argument,peerGuid:peer?.autonomy?.characterGuid??0,peerAccountId:peer?.autonomy?.accountId??0,peerName:peer?.name??'',expiresAt:Math.floor(Date.now()/1000)+90});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function classicPlayerReply(bot:Bot,request:{speakerGuid?:number;speakerAccountId?:number;speakerName:string;channel:'say'|'whisper'},reply:string){
|
||||||
|
if(!Number.isInteger(request.speakerGuid)||!request.speakerGuid||request.speakerGuid<1||!Number.isInteger(request.speakerAccountId)||!request.speakerAccountId||request.speakerAccountId<1||!/^[A-Za-z]{2,12}$/.test(request.speakerName))throw new Error('Verified Classic human identity required');
|
||||||
|
const command=JSON.parse(classicCommand('intent',bot,'socialize'));
|
||||||
|
return JSON.stringify({...command,kind:request.channel==='whisper'?'reply-whisper':'reply-say',argument:reply,peerGuid:request.speakerGuid,peerAccountId:request.speakerAccountId,peerName:request.speakerName});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import {classicCommand} from '../classic';
|
||||||
|
import {queueAleCommand} from '../ale-store';
|
||||||
|
import {runGM} from '../gm';
|
||||||
import type { GameConnector,Bot,Decision,RuntimeConfig } from '../types';
|
import type { GameConnector,Bot,Decision,RuntimeConfig } from '../types';
|
||||||
|
|
||||||
const deathSignal=z.object({
|
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}));}
|
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.
|
// 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 ConversationChannel='say'|'whisper';
|
||||||
|
|
||||||
export type ConversationRequest={
|
export type ConversationRequest={
|
||||||
|
speakerGuid?:number;
|
||||||
|
speakerAccountId?:number;
|
||||||
socialTurn?:0|1|2|3;
|
socialTurn?:0|1|2|3;
|
||||||
id:string;
|
id:string;
|
||||||
createdAt:string;
|
createdAt:string;
|
||||||
@@ -16,6 +18,7 @@ export type ConversationRequest={
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function ensure(db:D1Database){
|
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 (
|
await db.prepare(`CREATE TABLE IF NOT EXISTS conversation_requests (
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
created_at TEXT 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,'selectedPlayer',40),
|
||||||
text(payload,'nearbyBots',1000)
|
text(payload,'nearbyBots',1000)
|
||||||
).run();
|
).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};
|
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
|
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>();
|
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;
|
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 startedAt=new Date().toISOString();
|
||||||
const updated=await db.prepare(`UPDATE conversation_requests
|
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;
|
if(updated.meta.changes!==1)return null;
|
||||||
|
|
||||||
return {
|
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,
|
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,
|
id:row.id,
|
||||||
createdAt:row.created_at,
|
createdAt:row.created_at,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {classicMode,classicCommand} from './classic';
|
import {classicMode,classicCommand,classicPlayerReply} from './classic';
|
||||||
import {bondEvidence,socialEntry,communityTick} from './community';
|
import {bondEvidence,socialEntry,communityTick} from './community';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { queueAleCommand } from './ale-store';
|
import { queueAleCommand } from './ale-store';
|
||||||
@@ -203,8 +203,7 @@ export async function runConversationTick(store:StateStore,db:D1Database,config:
|
|||||||
reply=cleanReply(reply);
|
reply=cleanReply(reply);
|
||||||
if(!reply)throw new Error('Conversation reply was empty');
|
if(!reply)throw new Error('Conversation reply was empty');
|
||||||
|
|
||||||
if(classicMode(config)&&request.socialTurn===undefined)throw new Error('Classic player chat delivery is not enabled yet');
|
const source=classicMode(config)?(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 source=classicMode(config)?classicCommand('say',bot,reply,byName(state,request.speakerName)):compileReply(bot.name,request.speakerName,reply,request.channel,request.socialTurn!==undefined);
|
|
||||||
const command=await queueAleCommand(db,source);
|
const command=await queueAleCommand(db,source);
|
||||||
await awaitDelivery(db,request,bot.id,reply,command.id);
|
await awaitDelivery(db,request,bot.id,reply,command.id);
|
||||||
await cleanupConversations(db);
|
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,''');
|
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){
|
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(!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(!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');
|
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 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];
|
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');
|
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);
|
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);
|
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){
|
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);
|
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){
|
}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={}) {
|
export async function execute(store:StateStore,input:unknown,config:RuntimeConfig={}) {
|
||||||
const a=actionSchema.parse(input);let s=await store.load();
|
const a=actionSchema.parse(input);let s=await store.load();
|
||||||
const connector=connectors[s.settings.connector](config);
|
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);
|
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(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.');}
|
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.`);
|
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'){
|
}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!==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.');
|
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};
|
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'}.`);
|
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 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 LLMProvider {id:string; decide(bot:Bot,observation:Observation):Promise<Decision>}
|
||||||
export interface StateStore {load():Promise<State>; save(state:State,expectedRevision:number):Promise<void>}
|
export interface StateStore {load():Promise<State>; save(state:State,expectedRevision:number):Promise<void>}
|
||||||
export type RuntimeConfig = {GAME_BACKEND?:string;SOAP_URL?:string; SOAP_USER?:string; SOAP_PASSWORD?:string; BRIDGE_URL?:string; BRIDGE_TOKEN?:string; ALE_BRIDGE_TOKEN?:string; ALE_OPERATOR_TOKEN?:string; ALE_RAW_EXEC_ENABLED?:string; AUTONOMY_TOKEN?:string; LLM_URL?:string; LLM_API_KEY?:string};
|
export type RuntimeConfig = {DB?:D1Database;GAME_BACKEND?:string;SOAP_URL?:string; SOAP_USER?:string; SOAP_PASSWORD?:string; BRIDGE_URL?:string; BRIDGE_TOKEN?:string; ALE_BRIDGE_TOKEN?:string; ALE_OPERATOR_TOKEN?:string; ALE_RAW_EXEC_ENABLED?:string; AUTONOMY_TOKEN?:string; LLM_URL?:string; LLM_API_KEY?:string};
|
||||||
|
|||||||
@@ -49,11 +49,22 @@ validated typed commands into the Classic character database. It never executes
|
|||||||
Lua or arbitrary game commands. Native receipts distinguish accepted intentions
|
Lua or arbitrary game commands. Native receipts distinguish accepted intentions
|
||||||
from observed gameplay outcomes; interrupted commands are not replayed.
|
from observed gameplay outcomes; interrupted commands are not replayed.
|
||||||
|
|
||||||
Remaining cutover gates include live quest/training/survival observations,
|
`install-staging-services.py --root ROOT` installs isolated user services for
|
||||||
event telemetry and human-player conversation parity, dashboard/operator actions,
|
port 8791, the native relay, and autonomy. Stop manually launched staging
|
||||||
normal resurrection, actual party acceptance and speech, authentication/client
|
processes first. Services restart on failure and leave the live Wrath services
|
||||||
configuration, and a verified rollback route. Human-player chat delivery is
|
unchanged. The worker credential file is private to the owner.
|
||||||
explicitly disabled in the staged Classic adapter until implemented and tested.
|
|
||||||
|
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:
|
Migration safeguards:
|
||||||
|
|
||||||
@@ -68,3 +79,22 @@ Migration safeguards:
|
|||||||
not proof that corresponding game characters can safely be deleted.
|
not proof that corresponding game characters can safely be deleted.
|
||||||
- Test native bot login, progress, resurrection, safety rules, read-only visitor
|
- Test native bot login, progress, resurrection, safety rules, read-only visitor
|
||||||
access, and current observations before switching the live dashboard or client.
|
access, and current observations before switching the live dashboard or client.
|
||||||
|
|
||||||
|
Native outcome hooks now report quest completion/rewards, loot item counts,
|
||||||
|
looted copper, deaths, resurrection and learned spells. These are separate from
|
||||||
|
accepted intentions. The configured native auto-learn policy is invoked on
|
||||||
|
managed level changes; this is not a claim of trainer purchases. Player say and
|
||||||
|
whisper events bind the sender's native character/account identity and replies
|
||||||
|
revalidate that identity. Basic GM commands use Classic's own SOAP namespace;
|
||||||
|
Wrath-only Lua operator calls are rejected explicitly.
|
||||||
|
|
||||||
|
`prepare-auth.py --root ROOT` prepares a private Classic service GM account,
|
||||||
|
copies only ENDAMI's existing SRP credentials into the isolated authentication
|
||||||
|
database, and disables upstream sample logins. `check-auth.py --root ROOT`
|
||||||
|
verifies a complete SRP handshake and realm listing without entering the game.
|
||||||
|
`health.py --root ROOT --url URL` reports host health without Wrath SQL.
|
||||||
|
|
||||||
|
`cutover.py --root ROOT` requires a passing build and all 50 fresh staging bots,
|
||||||
|
backs up stopped Wrath accounts/characters, switches the existing endpoints, and
|
||||||
|
automatically restores Wrath endpoints if login or population verification fails.
|
||||||
|
Miniflare metadata databases are excluded from game-state backup selection.
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Bounded daily backup of Classic persistent accounts, characters and dashboard."""
|
||||||
|
import argparse,gzip,json,os,pathlib,shutil,sqlite3,subprocess,time
|
||||||
|
p=argparse.ArgumentParser();p.add_argument('--root',type=pathlib.Path,required=True);a=p.parse_args();root=a.root
|
||||||
|
base=root/'backups';base.mkdir(mode=0o700,exist_ok=True)
|
||||||
|
target=base/time.strftime('%Y%m%dT%H%M%SZ',time.gmtime());target.mkdir(mode=0o700)
|
||||||
|
c=json.loads((root/'database-credentials.json').read_text())
|
||||||
|
try:
|
||||||
|
with gzip.open(target/'classic-auth-characters.sql.gz','wb') as out:
|
||||||
|
p=subprocess.Popen(['docker','exec','-e','MYSQL_PWD','azerothai-classic-db','mysqldump','-uroot','--single-transaction','--set-gtid-purged=OFF','--databases','classicrealmd','classiccharacters'],stdout=subprocess.PIPE,stderr=subprocess.PIPE,env=dict(os.environ,MYSQL_PWD=c['root']))
|
||||||
|
shutil.copyfileobj(p.stdout,out)
|
||||||
|
if p.wait():raise RuntimeError('Classic backup failed')
|
||||||
|
dbs=[p for p in (root/'app-staging/.wrangler/state/v3/d1').rglob('*.sqlite') if p.name!='metadata.sqlite'];assert len(dbs)==1
|
||||||
|
source=sqlite3.connect(dbs[0]);dest=sqlite3.connect(target/'dashboard.sqlite');source.backup(dest);dest.close();source.close()
|
||||||
|
shutil.copytree(root/'run/etc',target/'config')
|
||||||
|
(target/'complete.json').write_text(json.dumps({'at':time.time(),'backend':'cmangos-classic'}))
|
||||||
|
for old in sorted(base.iterdir(),reverse=True)[3:]:
|
||||||
|
if old.is_dir() and (old/'complete.json').exists():shutil.rmtree(old)
|
||||||
|
print('Classic backup complete: '+str(target))
|
||||||
|
except Exception:
|
||||||
|
(target/'failed').touch();raise
|
||||||
@@ -58,11 +58,13 @@ def main(root):
|
|||||||
existing=mysql('SELECT name FROM characters').splitlines()
|
existing=mysql('SELECT name FROM characters').splitlines()
|
||||||
if any(name not in {b['name'] for b in roster} for name in existing):raise RuntimeError('Unexpected characters in isolated database; refusing to modify')
|
if any(name not in {b['name'] for b in roster} for name in existing):raise RuntimeError('Unexpected characters in isolated database; refusing to modify')
|
||||||
mysql((root/'adapter/schema.sql').read_text())
|
mysql((root/'adapter/schema.sql').read_text())
|
||||||
|
engine=mysql("SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA='classiccharacters' AND TABLE_NAME='ai_playerbot_item_info_cache'")
|
||||||
|
if engine!='InnoDB':mysql('ALTER TABLE ai_playerbot_item_info_cache ENGINE=InnoDB, ROW_FORMAT=DYNAMIC;')
|
||||||
(root/'run/logs').mkdir(exist_ok=True)
|
(root/'run/logs').mkdir(exist_ok=True)
|
||||||
values={'DataDir':'"/work/extracted"','LogsDir':'"/work/run/logs"','WorldServerPort':8086,'Console.Enable':1,'Ra.Enable':0,'SOAP.Enabled':0,'AzerothAI.Enable':0,'AzerothAI.RestrictBotLogin':1}
|
values={'DataDir':'"/work/extracted"','LogsDir':'"/work/run/logs"','WorldServerPort':8086,'Console.Enable':1,'Ra.Enable':0,'SOAP.Enabled':0,'AzerothAI.Enable':0,'AzerothAI.RestrictBotLogin':1}
|
||||||
for key,db in [('Login','classicrealmd'),('World','classicmangos'),('Character','classiccharacters'),('Logs','classiclogs')]:values[key+'DatabaseInfo']='"azerothai-classic-db;3306;'+credentials['user']+';'+credentials['password']+';'+db+'"'
|
for key,db in [('Login','classicrealmd'),('World','classicmangos'),('Character','classiccharacters'),('Logs','classiclogs')]:values[key+'DatabaseInfo']='"azerothai-classic-db;3306;'+credentials['user']+';'+credentials['password']+';'+db+'"'
|
||||||
configure('mangosd',values)
|
configure('mangosd',values)
|
||||||
configure('aiplayerbot',{'AiPlayerbot.Enabled':1,'AiPlayerbot.RandomBotAutologin':0,'AiPlayerbot.RandomBotLoginAtStartup':0,'AiPlayerbot.BotAutologin':0,'AiPlayerbot.RandomBotAutoCreate':0,'AiPlayerbot.MinRandomBots':50,'AiPlayerbot.MaxRandomBots':50,'AiPlayerbot.RandomBotAccountCount':0,'AiPlayerbot.RandomBotAccountPrefix':'AZCLASSIC','AiPlayerbot.RandomBotGuildCount':0,'AiPlayerbot.RandomBotArenaTeamCount':0,'AiPlayerbot.DisableRandomLevels':1,'AiPlayerbot.RandomGearUpgradeEnabled':0,'AiPlayerbot.AutoLearnTrainerSpells':1,'AiPlayerbot.AutoTrainSpells':'yes','AiPlayerbot.RandomBotJoinBG':0,'AiPlayerbot.RandomBotJoinLfg':0,'AiPlayerbot.RandomBotMaps':'0,1'})
|
configure('aiplayerbot',{'AiPlayerbot.Enabled':1,'AiPlayerbot.DisableBotOptimizations':1,'AiPlayerbot.DisableActivityPriorities':1,'AiPlayerbot.botActiveAlone':100,'AiPlayerbot.RandomBotAutologin':0,'AiPlayerbot.RandomBotLoginAtStartup':0,'AiPlayerbot.BotAutologin':0,'AiPlayerbot.RandomBotAutoCreate':0,'AiPlayerbot.MinRandomBots':50,'AiPlayerbot.MaxRandomBots':50,'AiPlayerbot.RandomBotAccountCount':0,'AiPlayerbot.RandomBotAccountPrefix':'AZCLASSIC','AiPlayerbot.RandomBotGuildCount':0,'AiPlayerbot.RandomBotArenaTeamCount':0,'AiPlayerbot.DisableRandomLevels':1,'AiPlayerbot.RandomGearUpgradeEnabled':0,'AiPlayerbot.AutoLearnTrainerSpells':1,'AiPlayerbot.AutoTrainSpells':'yes','AiPlayerbot.RandomBotJoinBG':0,'AiPlayerbot.RandomBotJoinLfg':0,'AiPlayerbot.RandomBotMaps':'0,1'})
|
||||||
fifo=root/'run/console'
|
fifo=root/'run/console'
|
||||||
if not fifo.exists():os.mkfifo(fifo,0o600)
|
if not fifo.exists():os.mkfifo(fifo,0o600)
|
||||||
state=inspect('azerothai-classic-world')
|
state=inspect('azerothai-classic-world')
|
||||||
@@ -70,13 +72,16 @@ def main(root):
|
|||||||
subprocess.run(['docker','run','-d','--name','azerothai-classic-world','--network','azerothai-classic','--cpus','2','--memory','3g','--user','1000:1000','-v',str(root)+':/work','-w','/work/run/bin','-p','127.0.0.1:8086:8086','azerothai-classic-builder:local','bash','-c','exec 3<>/work/run/console; exec ./mangosd -c /work/run/etc/mangosd.conf <&3'],check=True,capture_output=True)
|
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)
|
elif not state['Running']:subprocess.run(['docker','start','azerothai-classic-world'],check=True,capture_output=True)
|
||||||
status('starting isolated Classic world; bot login disabled')
|
status('starting isolated Classic world; bot login disabled')
|
||||||
for _ in range(120):
|
for attempt in range(720):
|
||||||
state=inspect('azerothai-classic-world')
|
state=inspect('azerothai-classic-world')
|
||||||
if not state or not state['Running']:raise RuntimeError('Classic world exited during startup; inspect container log')
|
if not state or not state['Running']:raise RuntimeError('Classic world exited during startup; inspect container log')
|
||||||
log=subprocess.run(['docker','logs','--tail','60','azerothai-classic-world'],capture_output=True,text=True).stdout
|
log=subprocess.run(['docker','logs','--since',state['StartedAt'],'azerothai-classic-world'],capture_output=True,text=True).stdout
|
||||||
if 'SERVER STARTUP TIME' in log or 'World initialized' in log:break
|
if '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)
|
time.sleep(5)
|
||||||
else:raise RuntimeError('Classic startup not confirmed within ten minutes')
|
else:raise RuntimeError('Classic startup not confirmed within sixty minutes; inspect cache progress')
|
||||||
|
# Restore full durability before any persistent characters are created.
|
||||||
|
mysql('SET GLOBAL innodb_flush_log_at_trx_commit=1; SET GLOBAL sync_binlog=1;')
|
||||||
status('creating 50 level-one native characters; bot login disabled')
|
status('creating 50 level-one native characters; bot login disabled')
|
||||||
with fifo.open('w') as console:
|
with fifo.open('w') as console:
|
||||||
for bot in roster:
|
for bot in roster:
|
||||||
@@ -97,6 +102,10 @@ def main(root):
|
|||||||
bound.append(dict(bot,characterGuid=guid,accountId=account))
|
bound.append(dict(bot,characterGuid=guid,accountId=account))
|
||||||
if int(mysql('SELECT COUNT(*) FROM characters'))!=50:raise RuntimeError('Population differs from 50')
|
if int(mysql('SELECT COUNT(*) FROM characters'))!=50:raise RuntimeError('Population differs from 50')
|
||||||
(root/'classic-roster-bound.json').write_text(json.dumps(bound,indent=2)+'\n')
|
(root/'classic-roster-bound.json').write_text(json.dumps(bound,indent=2)+'\n')
|
||||||
|
# Native login authorization reloads this account range on restart.
|
||||||
|
account_limit=int(mysql("SELECT COALESCE(MAX(CAST(SUBSTRING(username,10) AS UNSIGNED))+1,0) FROM classicrealmd.account WHERE username REGEXP '^AZCLASSIC[0-9]+$'"))
|
||||||
|
config_path=root/'run/etc/aiplayerbot.conf'
|
||||||
|
config_path.write_text(re.sub(r'^AiPlayerbot.RandomBotAccountCount\s*=.*$',f'AiPlayerbot.RandomBotAccountCount = {account_limit}',config_path.read_text(),flags=re.M))
|
||||||
status('Classic world ready; 50 level-one characters verified offline; connector and gameplay validation remain',characters=50,alliance=25,horde=25)
|
status('Classic world ready; 50 level-one characters verified offline; connector and gameplay validation remain',characters=50,alliance=25,horde=25)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
status('bootstrap blocked',error=str(error).replace(credentials['root'],'[redacted]').replace(credentials['password'],'[redacted]'))
|
status('bootstrap blocked',error=str(error).replace(credentials['root'],'[redacted]').replace(credentials['password'],'[redacted]'))
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- The upstream item-info builder already batches its writes in a transaction.
|
||||||
|
-- MyISAM ignores that transaction and syncs each statement to the binary log.
|
||||||
|
-- Use InnoDB so every restart retains full durability without per-row fsync.
|
||||||
|
-- Apply to the isolated Classic character database before starting mangosd.
|
||||||
|
ALTER TABLE ai_playerbot_item_info_cache ENGINE=InnoDB, ROW_FORMAT=DYNAMIC;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify Classic SRP login and realm listing without entering the game world."""
|
||||||
|
import argparse,hashlib,json,pathlib,secrets,socket,struct
|
||||||
|
|
||||||
|
def verify(host,port,credentials):
|
||||||
|
username=credentials['username'].upper().encode();password=credentials['password'].upper().encode()
|
||||||
|
def h(data):return hashlib.sha1(data).digest()
|
||||||
|
with socket.create_connection((host,port),timeout=8) as sock:
|
||||||
|
def read(n):
|
||||||
|
data=b''
|
||||||
|
while len(data)<n:
|
||||||
|
block=sock.recv(n-len(data))
|
||||||
|
if not block:raise RuntimeError('Authentication connection closed')
|
||||||
|
data+=block
|
||||||
|
return data
|
||||||
|
payload=b'WoW\0'+bytes([1,12,1])+struct.pack('<H',5875)+b'68x\0'+b'niW\0'+b'SUne'+struct.pack('<I',0)+socket.inet_aton('127.0.0.1')+bytes([len(username)])+username
|
||||||
|
sock.sendall(bytes([0,0])+struct.pack('<H',len(payload))+payload)
|
||||||
|
header=read(3)
|
||||||
|
if header!=b'\0\0\0':raise RuntimeError('Classic challenge rejected: '+header.hex())
|
||||||
|
Braw=read(32);graw=read(read(1)[0]);Nraw=read(read(1)[0]);salt=read(32);read(16)
|
||||||
|
flags=read(1)[0]
|
||||||
|
if flags:raise RuntimeError('Unexpected extra authentication factor')
|
||||||
|
N=int.from_bytes(Nraw,'little');g=int.from_bytes(graw,'little');B=int.from_bytes(Braw,'little')
|
||||||
|
a=secrets.randbits(256);A=pow(g,a,N).to_bytes(32,'little')
|
||||||
|
x=int.from_bytes(h(salt+h(username+b':'+password)),'little')
|
||||||
|
u=int.from_bytes(h(A+Braw),'little')
|
||||||
|
secret=pow((B-3*pow(g,x,N))%N,a+u*x,N).to_bytes(32,'little')
|
||||||
|
even=h(secret[::2]);odd=h(secret[1::2]);key=bytes(v for pair in zip(even,odd) for v in pair)
|
||||||
|
proof=h(bytes(x^y for x,y in zip(h(Nraw),h(graw)))+h(username)+salt+A+Braw+key)
|
||||||
|
sock.sendall(b'\x01'+A+proof+bytes(20)+b'\0\0')
|
||||||
|
result=read(2)
|
||||||
|
if result!=b'\x01\0':raise RuntimeError('Classic password proof rejected: '+result.hex())
|
||||||
|
if read(20)!=h(A+proof+key):raise RuntimeError('Server password proof did not match')
|
||||||
|
read(4)
|
||||||
|
sock.sendall(b'\x10'+bytes(4))
|
||||||
|
if read(1)!=b'\x10':raise RuntimeError('Unexpected realm-list response')
|
||||||
|
realms=read(struct.unpack('<H',read(2))[0])
|
||||||
|
if b'AzerothAI Classic\0' not in realms:raise RuntimeError('Expected Classic realm missing')
|
||||||
|
print('Classic password proof, server proof and AzerothAI Classic realm listing verified.')
|
||||||
|
|
||||||
|
if __name__=='__main__':
|
||||||
|
p=argparse.ArgumentParser();p.add_argument('--root',type=pathlib.Path,required=True);p.add_argument('--host',default='127.0.0.1');p.add_argument('--port',type=int,default=3725);a=p.parse_args()
|
||||||
|
verify(a.host,a.port,json.loads((a.root/'classic-gm-credentials.json').read_text()))
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Switch the validated Classic staging realm into the existing local endpoints.
|
||||||
|
Preserves Wrath containers, user services, database and a final database dump.
|
||||||
|
On an error restores the former endpoints without overwriting either database.
|
||||||
|
"""
|
||||||
|
import argparse,gzip,json,os,pathlib,shutil,sqlite3,subprocess,time,urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def main(root):
|
||||||
|
home=pathlib.Path.home();units=home/'.config/systemd/user';app=root/'app-staging'
|
||||||
|
archive=root/('cutover-backup-'+str(int(time.time())));archive.mkdir(mode=0o700,exist_ok=False)
|
||||||
|
def run(args,check=True):
|
||||||
|
p=subprocess.run(args,capture_output=True,text=True)
|
||||||
|
if check and p.returncode:raise RuntimeError('Command failed: '+' '.join(args[:4])+' '+p.stderr[-500:])
|
||||||
|
return p.stdout.strip()
|
||||||
|
def status(stage,**extra):
|
||||||
|
value={'at':time.time(),'stage':stage,**extra};(root/'cutover-status.json').write_text(json.dumps(value,indent=2));print(stage,flush=True)
|
||||||
|
def state():return json.load(urllib.request.urlopen('http://127.0.0.1:8791/api/state',timeout=10))
|
||||||
|
s=state();bots=s['bots']
|
||||||
|
assert (root/'final-build-passed').exists()
|
||||||
|
assert len(bots)==50 and all(b['status']=='online' and time.time()-b['world']['at']<20 for b in bots)
|
||||||
|
assert sum(b['world']['team']==0 for b in bots)==25 and sum(b['world']['team']==1 for b in bots)==25
|
||||||
|
assert not s['settings']['hardcore']['enabled']
|
||||||
|
(archive/'classic-state.json').write_text(json.dumps(s))
|
||||||
|
old=['azerothai-runtime','azerothai-autonomy','azerothai-ale-relay','azerothai-operations.timer','azerothai-backup.timer']
|
||||||
|
classic=['azerothai-classic-runtime','azerothai-classic-relay','azerothai-classic-autonomy']
|
||||||
|
for file in units.glob('azerothai*'):shutil.copy2(file,archive/file.name)
|
||||||
|
shutil.copy2(app/'.dev.vars',archive/'classic.dev.vars');shutil.copy2(root/'staging-worker.env',archive/'staging-worker.env')
|
||||||
|
changed=False
|
||||||
|
try:
|
||||||
|
status('Stopping services for the validated Classic cutover')
|
||||||
|
changed=True
|
||||||
|
run(['systemctl','--user','disable','--now',*old],False)
|
||||||
|
run(['systemctl','--user','stop','azerothai-operations.service','azerothai-backup.service','azerothai-spectator',*classic],False)
|
||||||
|
run(['docker','stop','-t','30','ac-worldserver','ac-authserver','azerothai-dashboard-proxy'])
|
||||||
|
run(['docker','rename','azerothai-dashboard-proxy','azerothai-dashboard-proxy-wrath-rollback'])
|
||||||
|
status('Saving final stopped Wrath character and account databases')
|
||||||
|
cfg=json.loads(run(['docker','inspect','ac-database']))[0]
|
||||||
|
values=dict(v.split('=',1) for v in cfg['Config']['Env'] if '=' in v)
|
||||||
|
password=values.get('MYSQL_ROOT_PASSWORD') or values.get('MARIADB_ROOT_PASSWORD')
|
||||||
|
env=dict(os.environ,MYSQL_PWD=password)
|
||||||
|
with gzip.open(archive/'wrath-auth-characters.sql.gz','wb') as out:
|
||||||
|
proc=subprocess.Popen(['docker','exec','-e','MYSQL_PWD','ac-database','mysqldump','-uroot','--single-transaction','--set-gtid-purged=OFF','--databases','acore_auth','acore_characters'],stdout=subprocess.PIPE,stderr=subprocess.PIPE,env=env)
|
||||||
|
shutil.copyfileobj(proc.stdout,out)
|
||||||
|
if proc.wait():raise RuntimeError('Final Wrath database backup failed')
|
||||||
|
assert (archive/'wrath-auth-characters.sql.gz').stat().st_size>10000
|
||||||
|
status('Publishing Classic game and authentication ports')
|
||||||
|
run(['docker','stop','-t','30','azerothai-classic-world','azerothai-classic-auth'])
|
||||||
|
for name in ['azerothai-classic-world','azerothai-classic-auth']:
|
||||||
|
run(['docker','rm','-f',name+'-staging'],False)
|
||||||
|
run(['docker','rename',name,name+'-staging'])
|
||||||
|
common=['--network','azerothai-classic-access','--restart','unless-stopped','--user','1000:1000','-v',str(root)+':/work','-w','/work/run/bin']
|
||||||
|
run(['docker','create','--name','azerothai-classic-world',*common,'--cpus','2','--memory','3g','-p','8086:8086','azerothai-classic-builder:local','bash','-c','exec 3<>/work/run/console; exec ./mangosd -c /work/run/etc/mangosd.conf <&3'])
|
||||||
|
run(['docker','create','--name','azerothai-classic-auth',*common,'--cpus','0.5','--memory','128m','-p','3724:3724','azerothai-classic-builder:local','./realmd','-c','/work/run/etc/realmd.conf'])
|
||||||
|
for name in ['azerothai-classic-world','azerothai-classic-auth']:
|
||||||
|
run(['docker','network','connect','azerothai-classic',name]);run(['docker','start',name])
|
||||||
|
run(['docker','update','--restart','unless-stopped','azerothai-classic-db'])
|
||||||
|
ip=run(['docker','inspect','--format','{{with index .NetworkSettings.Networks "azerothai-classic-access"}}{{.IPAddress}}{{end}}','azerothai-classic-world'])
|
||||||
|
p=app/'.dev.vars';text=p.read_text();import re
|
||||||
|
text=re.sub(r'^SOAP_URL=.*$','SOAP_URL=http://'+ip+':7879',text,flags=re.M);p.write_text(text)
|
||||||
|
# Discard copied Wrath metrics; native Classic counters remain intact.
|
||||||
|
databases=[p for p in (app/'.wrangler/state/v3/d1').rglob('*.sqlite') if p.name!='metadata.sqlite']
|
||||||
|
assert len(databases)==1
|
||||||
|
db=sqlite3.connect(databases[0]);backup=sqlite3.connect(archive/'classic-before-cutover.sqlite');db.backup(backup);backup.close()
|
||||||
|
names=[x[0] for x in db.execute("SELECT name FROM sqlite_master WHERE type='table'")]
|
||||||
|
for name in names:
|
||||||
|
if name.startswith('insights_') or name=='operations_samples':db.execute('DELETE FROM "'+name+'"')
|
||||||
|
db.commit();db.close()
|
||||||
|
for name in classic:
|
||||||
|
p=units/(name+'.service');text=p.read_text().replace('8791','8787').replace('RUNTIME_HOST=127.0.0.1','RUNTIME_HOST=0.0.0.0');p.write_text(text)
|
||||||
|
p=root/'staging-worker.env';p.write_text(p.read_text().replace('8791','8787'))
|
||||||
|
p=units/'azerothai-spectator.service';p.write_text(p.read_text().replace('/home/endami/AzerothAI',str(app)).replace('After=network-online.target azerothai-runtime.service','After=network-online.target azerothai-classic-runtime.service'))
|
||||||
|
run(['systemctl','--user','daemon-reload']);run(['systemctl','--user','enable','--now',*classic,'azerothai-spectator'])
|
||||||
|
run(['docker','run','-d','--name','azerothai-dashboard-proxy','--restart','unless-stopped','--network','azerothai-mini','--add-host','host.docker.internal:host-gateway','--memory','128m','-p','8788:80','-v',str(home/'.config/azerothai/nginx.conf')+':/etc/nginx/conf.d/default.conf:ro','-v',str(app/'dist/client')+':/srv:ro','nginx:alpine'])
|
||||||
|
status('Classic endpoints active; waiting for all 50 fresh observations')
|
||||||
|
for _ in range(36):
|
||||||
|
try:
|
||||||
|
current=json.load(urllib.request.urlopen('http://127.0.0.1:8787/api/state',timeout=5));bs=current['bots']
|
||||||
|
if len(bs)==50 and all(b['status']=='online' and time.time()-b['world']['at']<20 for b in bs):break
|
||||||
|
except Exception:pass
|
||||||
|
time.sleep(5)
|
||||||
|
else:raise RuntimeError('Classic did not restore all 50 fresh observations')
|
||||||
|
assert sum(b['world']['team']==0 for b in bs)==25 and sum(b['world']['team']==1 for b in bs)==25
|
||||||
|
run(['python3',str(root/'adapter/check-auth.py'),'--root',str(root),'--port','3724'])
|
||||||
|
status('Classic cutover complete; 50 fresh bots, 25 Alliance and 25 Horde; login verified',cutover=True,backup=str(archive))
|
||||||
|
except Exception as error:
|
||||||
|
if changed:
|
||||||
|
run(['systemctl','--user','stop',*classic,'azerothai-spectator'],False)
|
||||||
|
run(['docker','stop','azerothai-classic-world','azerothai-classic-auth'],False)
|
||||||
|
if run(['docker','ps','-a','--filter','name=^azerothai-dashboard-proxy-wrath-rollback$','--format','{{.Names}}'],False):
|
||||||
|
run(['docker','rm','-f','azerothai-dashboard-proxy'],False);run(['docker','rename','azerothai-dashboard-proxy-wrath-rollback','azerothai-dashboard-proxy'],False)
|
||||||
|
for file in archive.glob('azerothai*'):shutil.copy2(file,units/file.name)
|
||||||
|
run(['systemctl','--user','daemon-reload'],False)
|
||||||
|
run(['docker','start','ac-worldserver','ac-authserver','azerothai-dashboard-proxy'],False)
|
||||||
|
run(['systemctl','--user','enable','--now',*old,'azerothai-spectator'],False)
|
||||||
|
status('Classic cutover blocked; former Wrath endpoints restored',error=str(error),cutover=False)
|
||||||
|
raise
|
||||||
|
|
||||||
|
if __name__=='__main__':
|
||||||
|
p=argparse.ArgumentParser();p.add_argument('--root',type=pathlib.Path,required=True);main(p.parse_args().root.resolve())
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Classic host readings only; never runs Wrath maintenance SQL."""
|
||||||
|
import argparse,json,pathlib,shutil,subprocess,time,urllib.request
|
||||||
|
p=argparse.ArgumentParser();p.add_argument('--root',type=pathlib.Path,required=True);p.add_argument('--url',default='http://127.0.0.1:8787');a=p.parse_args()
|
||||||
|
v={}
|
||||||
|
for line in (a.root/'app-staging/.dev.vars').read_text().splitlines():
|
||||||
|
if '=' in line:
|
||||||
|
k,value=line.split('=',1);v[k]=value.strip().strip('"\'')
|
||||||
|
def cpu():
|
||||||
|
values=list(map(int,pathlib.Path('/proc/stat').read_text().splitlines()[0].split()[1:]));return sum(values[:8]),sum(values[3:5])
|
||||||
|
x=cpu();time.sleep(1);y=cpu()
|
||||||
|
m={l.split(':')[0]:int(l.split()[1])/1024 for l in pathlib.Path('/proc/meminfo').read_text().splitlines()}
|
||||||
|
model=False
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(v['LLM_URL'].rstrip('/')+'/api/tags',timeout=5) as response:model=response.status==200
|
||||||
|
except Exception:pass
|
||||||
|
body=dict(host='ubuntu',at=time.time(),cpuPercent=max(0,min(100,100*(1-(y[1]-x[1])/max(1,y[0]-x[0])))),memoryTotalMB=m['MemTotal'],memoryAvailableMB=m['MemAvailable'],diskFreeGB=shutil.disk_usage(a.root).free/1024**3,modelAvailable=model,runtimeService=subprocess.check_output(['systemctl','--user','is-active','azerothai-classic-runtime'],text=True).strip(),maintenance='Classic telemetry active; Wrath maintenance disabled')
|
||||||
|
req=urllib.request.Request(a.url+'/api/operations',data=json.dumps(body).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+v['AUTONOMY_TOKEN']})
|
||||||
|
with urllib.request.urlopen(req,timeout=15) as response:assert response.status==200
|
||||||
@@ -37,6 +37,36 @@ def install(source):
|
|||||||
anchor = 'void PlayerbotHolder::AddPlayerBot(uint32 playerGuid, uint32 masterAccount)\n{'
|
anchor = 'void PlayerbotHolder::AddPlayerBot(uint32 playerGuid, uint32 masterAccount)\n{'
|
||||||
patch(path, anchor, anchor + '\n if (!AzerothAIAllowBotLogin(playerGuid)) return;')
|
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__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("source", type=pathlib.Path)
|
parser.add_argument("source", type=pathlib.Path)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Install isolated, restartable Classic dashboard/relay/autonomy user services.
|
||||||
|
|
||||||
|
Prepare staging state first and stop any manually started staging processes.
|
||||||
|
This does not enable game login, start authentication, or change live services.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
def main(root):
|
||||||
|
app = root / 'app-staging'
|
||||||
|
node = pathlib.Path.home() / '.local/bin/node'
|
||||||
|
for path in [node, app / '.dev.vars', root / 'adapter/relay.py']:
|
||||||
|
if not path.is_file():
|
||||||
|
raise RuntimeError('Missing prerequisite: ' + str(path))
|
||||||
|
if any(c in str(root) for c in [' ', '\n', '"', "'", '%']):
|
||||||
|
raise RuntimeError('Use a simple absolute staging path')
|
||||||
|
values = {}
|
||||||
|
for line in (app / '.dev.vars').read_text().splitlines():
|
||||||
|
if '=' in line and not line.lstrip().startswith('#'):
|
||||||
|
key, value = line.split('=', 1)
|
||||||
|
values[key.strip()] = value.strip().strip('"\'')
|
||||||
|
token = values['AUTONOMY_TOKEN']
|
||||||
|
if not token or any(c in token for c in ['\n', '\r', '"', '\\']):
|
||||||
|
raise RuntimeError('Invalid autonomy token format')
|
||||||
|
environment = root / 'staging-worker.env'
|
||||||
|
fd = os.open(environment, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
with os.fdopen(fd, 'w') as out:
|
||||||
|
out.write('AUTONOMY_TOKEN="' + token + '"\nAZEROTHAI_URL=http://127.0.0.1:8791\n')
|
||||||
|
environment.chmod(0o600)
|
||||||
|
units = pathlib.Path.home() / '.config/systemd/user'
|
||||||
|
units.mkdir(parents=True, exist_ok=True)
|
||||||
|
commands = {
|
||||||
|
'runtime': (f'{node} {app}/scripts/start-local-runtime.mjs', 'Environment=RUNTIME_PORT=8791 RUNTIME_HOST=127.0.0.1\n'),
|
||||||
|
'relay': (f'/usr/bin/sg docker -c "/usr/bin/python3 {root}/adapter/relay.py --root {root} --url http://127.0.0.1:8791 --env-file {app}/.dev.vars"', ''),
|
||||||
|
'autonomy': (f'{node} {app}/scripts/autonomy-worker.mjs', f'EnvironmentFile={environment}\n'),
|
||||||
|
}
|
||||||
|
names = []
|
||||||
|
for kind, (command, extra) in commands.items():
|
||||||
|
name = f'azerothai-classic-{kind}.service'
|
||||||
|
names.append(name)
|
||||||
|
after = 'network.target' if kind == 'runtime' else 'azerothai-classic-runtime.service'
|
||||||
|
(units / name).write_text(f'''[Unit]
|
||||||
|
Description=AzerothAI Classic staging {kind}
|
||||||
|
After={after}
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory={app}
|
||||||
|
Environment=PATH={node.parent}:/usr/local/bin:/usr/bin:/bin
|
||||||
|
{extra}ExecStart={command}
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
TimeoutStopSec=20
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
''')
|
||||||
|
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
|
||||||
|
subprocess.run(['systemctl', '--user', 'enable', '--now', *names], check=True)
|
||||||
|
subprocess.run(['systemctl', '--user', 'is-active', *names], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--root', type=pathlib.Path, required=True)
|
||||||
|
main(parser.parse_args().root.resolve())
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
#include "Config/Config.h"
|
#include "Config/Config.h"
|
||||||
#include "Database/DatabaseEnv.h"
|
#include "Database/DatabaseEnv.h"
|
||||||
#include "playerbot/PlayerbotAI.h"
|
#include "playerbot/PlayerbotAI.h"
|
||||||
|
#include "playerbot/PlayerbotAIConfig.h"
|
||||||
#include "playerbot/RandomPlayerbotMgr.h"
|
#include "playerbot/RandomPlayerbotMgr.h"
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
@@ -61,9 +62,16 @@ void commands() {
|
|||||||
if (!CharacterDatabase.DirectPExecute("UPDATE azerothai_commands SET status='processing' WHERE id='%s' AND status='pending'",sql(id).c_str())) continue;
|
if (!CharacterDatabase.DirectPExecute("UPDATE azerothai_commands SET status='processing' WHERE id='%s' AND status='pending'",sql(id).c_str())) continue;
|
||||||
Player* p=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,f[1].GetUInt32()));
|
Player* p=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,f[1].GetUInt32()));
|
||||||
Player* peer=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,f[6].GetUInt32()));
|
Player* peer=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,f[6].GetUInt32()));
|
||||||
if (!managed(p) || p->GetSession()->GetAccountId()!=f[2].GetUInt32() || p->GetName()!=f[3].GetString() || std::time(nullptr)-f[7].GetUInt32()>90 || std::time(nullptr)>f[10].GetUInt32()) { receipt(id,false,"Stale command or managed identity unavailable"); continue; }
|
if (!managed(p) || p->GetSession()->GetAccountId()!=f[2].GetUInt32() || std::string(p->GetName())!=f[3].GetString() || std::time(nullptr)-f[7].GetUInt32()>90 || std::time(nullptr)>f[10].GetUInt32()) { receipt(id,false,"Stale command or managed identity unavailable"); continue; }
|
||||||
if (f[6].GetUInt32() && (!managed(peer) || peer->GetSession()->GetAccountId()!=f[8].GetUInt32() || peer->GetName()!=f[9].GetString())) {receipt(id,false,"Peer identity changed or protected");continue;}
|
bool humanReply=kind=="reply-say" || kind=="reply-whisper";
|
||||||
if (kind=="party") {
|
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; }
|
if (!nearby(p,peer,35) || !safeGroup(p) || !safeGroup(peer) || peer->GetGroup() || peer->GetGroupInvite() || p->GetGroupInvite() || (p->GetGroup() && (p->GetGroup()->GetMembersCount()>=5 || p->GetGroup()->GetLeaderGuid()!=p->GetObjectGuid()))) { receipt(id,false,"Party participants unavailable or protected"); continue; }
|
||||||
WorldPacket invite(CMSG_GROUP_INVITE,32); invite << std::string(peer->GetName()); p->GetSession()->HandleGroupInviteOpcode(invite);
|
WorldPacket invite(CMSG_GROUP_INVITE,32); invite << std::string(peer->GetName()); p->GetSession()->HandleGroupInviteOpcode(invite);
|
||||||
if (peer->GetGroupInvite()) { WorldPacket accept(CMSG_GROUP_ACCEPT,0); peer->GetSession()->HandleGroupAcceptOpcode(accept); }
|
if (peer->GetGroupInvite()) { WorldPacket accept(CMSG_GROUP_ACCEPT,0); peer->GetSession()->HandleGroupAcceptOpcode(accept); }
|
||||||
@@ -139,6 +147,47 @@ void AzerothAIUpdate(uint32_t diff) {
|
|||||||
for(auto const& pair:roster){auto const&r=pair.second;Player* p=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,r.guid));
|
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(!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);
|
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();
|
commands();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void AzerothAIEvent(Player* p, const char* kind, uint32_t value, uint32_t amount) {
|
||||||
|
if (!sConfig.GetBoolDefault("AzerothAI.Enable",false) || !managed(p)) return;
|
||||||
|
std::string eventKind(kind), column;
|
||||||
|
if(eventKind=="player.quest.complete")column="quests";
|
||||||
|
else if(eventKind=="player.quest.reward")column="rewarded";
|
||||||
|
else if(eventKind=="player.loot.item")column="lootItems";
|
||||||
|
else if(eventKind=="player.loot.money")column="lootedCopper";
|
||||||
|
else if(eventKind=="player.death")column="nativeDeaths";
|
||||||
|
else if(eventKind=="player.spell.learn")column="learnedSpells";
|
||||||
|
else if(eventKind=="player.resurrect")column="resurrections";
|
||||||
|
if(!column.empty())CharacterDatabase.PExecute("INSERT INTO azerothai_counters(guid,%s) VALUES(%u,%u) ON DUPLICATE KEY UPDATE %s=%s+%u",column.c_str(),p->GetGUIDLow(),amount,column.c_str(),column.c_str(),amount);
|
||||||
|
if(eventKind=="player.loot.item")CharacterDatabase.PExecute("INSERT INTO azerothai_counters(guid,lootEvents) VALUES(%u,1) ON DUPLICATE KEY UPDATE lootEvents=lootEvents+1",p->GetGUIDLow());
|
||||||
|
std::ostringstream out;
|
||||||
|
out << "{\"isBot\":true,\"characterGuid\":" << p->GetGUIDLow() << ",\"accountId\":" << p->GetSession()->GetAccountId()
|
||||||
|
<< ",\"name\":" << quoted(p->GetName()) << ",\"level\":" << p->GetLevel() << ",\"observedAt\":" << std::time(nullptr)
|
||||||
|
<< ",\"value\":" << value << ",\"amount\":" << amount;
|
||||||
|
if(auto area=GetAreaEntryByAreaID(p->GetZoneId()))out << ",\"zoneName\":" << quoted(area->area_name[0]);
|
||||||
|
out << '}';
|
||||||
|
CharacterDatabase.PExecute("INSERT INTO azerothai_events(at,kind,payload) VALUES(UNIX_TIMESTAMP(),'%s','%s')",sql(eventKind).c_str(),sql(out.str()).c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void AzerothAIChat(Player* p, const std::string& message, Player* receiver) {
|
||||||
|
if(!sConfig.GetBoolDefault("AzerothAI.Enable",false) || !p || !p->IsInWorld() || (p->GetPlayerbotAI() && !p->GetPlayerbotAI()->IsRealPlayer()) || message.empty() || message.size()>500 || message[0]=='.' || message[0]=='!')return;
|
||||||
|
if(receiver && !managed(receiver))return;
|
||||||
|
std::string names;
|
||||||
|
for(auto const& r:roster){Player* bot=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,r.first));if(managed(bot) && bot->GetTeam()==p->GetTeam() && bot->GetMapId()==p->GetMapId() && bot->GetDistance(p)<=25){if(!names.empty())names+=",";names+=bot->GetName();}}
|
||||||
|
if(!receiver && names.empty())return;
|
||||||
|
std::ostringstream out;
|
||||||
|
out << "{\"isBot\":false,\"characterGuid\":" << p->GetGUIDLow() << ",\"accountId\":" << p->GetSession()->GetAccountId() << ",\"name\":" << quoted(p->GetName())
|
||||||
|
<< ",\"message\":" << quoted(message) << ",\"nearbyBots\":" << quoted(names) << ",\"receiver\":" << quoted(receiver?receiver->GetName():"") << ",\"receiverIsBot\":" << (receiver?"true":"false");
|
||||||
|
if(auto area=GetAreaEntryByAreaID(p->GetZoneId()))out << ",\"zoneName\":" << quoted(area->area_name[0]);
|
||||||
|
out << '}';
|
||||||
|
CharacterDatabase.PExecute("INSERT INTO azerothai_events(at,kind,payload) VALUES(UNIX_TIMESTAMP(),'%s','%s')",receiver?"player.whisper":"player.chat",sql(out.str()).c_str());
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,3 +4,8 @@ class Unit;
|
|||||||
void AzerothAIUpdate(uint32_t diff);
|
void AzerothAIUpdate(uint32_t diff);
|
||||||
uint32_t AzerothAIBoundDamage(Unit* victim, uint32_t damage);
|
uint32_t AzerothAIBoundDamage(Unit* victim, uint32_t damage);
|
||||||
bool AzerothAIAllowBotLogin(uint32_t guid);
|
bool AzerothAIAllowBotLogin(uint32_t guid);
|
||||||
|
class Player;
|
||||||
|
void AzerothAIEvent(Player* player, const char* kind, uint32_t value = 0, uint32_t amount = 1);
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
void AzerothAIChat(Player* player, const std::string& message, Player* receiver = nullptr);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Prepare isolated Classic authentication, preserving ENDAMI's SRP credentials.
|
||||||
|
Does not stop Wrath or alter any Wrath account. Secrets are never printed.
|
||||||
|
"""
|
||||||
|
import argparse,base64,hashlib,json,os,pathlib,re,secrets,subprocess
|
||||||
|
|
||||||
|
def main(root):
|
||||||
|
credentials=json.loads((root/'database-credentials.json').read_text())
|
||||||
|
def query(container,password,sql):
|
||||||
|
result=subprocess.run(['docker','exec','-i','-e','MYSQL_PWD',container,'mysql','-uroot','-N','-B'],input=sql,text=True,capture_output=True,env=dict(os.environ,MYSQL_PWD=password))
|
||||||
|
if result.returncode:raise RuntimeError('Authentication database operation failed')
|
||||||
|
return result.stdout.strip()
|
||||||
|
def classic(sql):return query('azerothai-classic-db',credentials['root'],sql)
|
||||||
|
account_file=root/'classic-gm-credentials.json'
|
||||||
|
if account_file.exists():gm=json.loads(account_file.read_text())
|
||||||
|
else:
|
||||||
|
gm={'username':'AZAIADMIN','password':secrets.token_hex(8)}
|
||||||
|
account_file.write_text(json.dumps(gm));account_file.chmod(0o600)
|
||||||
|
if gm['username'].startswith('AZCLASSIC'):
|
||||||
|
gm['username']='AZAIADMIN'
|
||||||
|
account_file.write_text(json.dumps(gm));account_file.chmod(0o600)
|
||||||
|
salt=secrets.token_bytes(32)
|
||||||
|
digest=hashlib.sha1((gm['username'].upper()+':'+gm['password'].upper()).encode()).digest()
|
||||||
|
exponent=int.from_bytes(hashlib.sha1(salt+digest).digest(),'little')
|
||||||
|
verifier=pow(7,exponent,int('894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7',16))
|
||||||
|
assert re.fullmatch('[A-Z0-9]+',gm['username'])
|
||||||
|
classic(f"INSERT INTO classicrealmd.account(username,s,v,gmlevel) VALUES ('{gm['username']}','{salt[::-1].hex()}','{verifier:X}',3) ON DUPLICATE KEY UPDATE gmlevel=3,s=VALUES(s),v=VALUES(v);")
|
||||||
|
# Disable upstream sample logins without deleting accounts or characters.
|
||||||
|
classic("UPDATE classicrealmd.account SET locked=1,lockedIp='disabled' WHERE id IN (1,2,3,4) AND username IN ('ADMINISTRATOR','GAMEMASTER','MODERATOR','PLAYER');")
|
||||||
|
config=json.loads(subprocess.check_output(['docker','inspect','ac-database']))[0]
|
||||||
|
env=dict(v.split('=',1) for v in config['Config']['Env'] if '=' in v)
|
||||||
|
password=env.get('MYSQL_ROOT_PASSWORD') or env.get('MARIADB_ROOT_PASSWORD')
|
||||||
|
row=query('ac-database',password,"SELECT a.username,HEX(a.salt),HEX(a.verifier),MAX(x.gmlevel) FROM acore_auth.account a JOIN acore_auth.account_access x ON x.id=a.id WHERE a.username='ENDAMI' GROUP BY a.id;").split('\t')
|
||||||
|
if len(row)!=4 or row[0]!='ENDAMI':raise RuntimeError('Expected existing human GM account was not found')
|
||||||
|
salt=bytes.fromhex(row[1])[::-1].hex();verifier=bytes.fromhex(row[2])[::-1].hex()
|
||||||
|
classic(f"INSERT INTO classicrealmd.account(username,s,v,gmlevel) VALUES ('ENDAMI','{salt}','{verifier}',3) ON DUPLICATE KEY UPDATE s=VALUES(s),v=VALUES(v),gmlevel=3;")
|
||||||
|
classic("UPDATE classicrealmd.realmlist SET name='AzerothAI Classic',address='10.0.0.125',port=8086,realmflags=0,allowedSecurityLevel=0 WHERE id=1;")
|
||||||
|
text=(root/'run/etc/realmd.conf.dist').read_text()
|
||||||
|
values={'LoginDatabaseInfo':'"azerothai-classic-db;3306;'+credentials['user']+';'+credentials['password']+';classicrealmd"','RealmServerPort':'3724','BindIP':'"0.0.0.0"','LogsDir':'"/work/run/logs"'}
|
||||||
|
for key,value in values.items():
|
||||||
|
pattern=r'^'+re.escape(key)+r'\s*=.*$';line=key+' = '+value
|
||||||
|
text=re.sub(pattern,lambda _:line,text,flags=re.M) if re.search(pattern,text,re.M) else text+'\n'+line+'\n'
|
||||||
|
path=root/'run/etc/realmd.conf';path.write_text(text);path.chmod(0o600)
|
||||||
|
print('Classic GM credentials prepared; ENDAMI password verifier preserved; sample logins disabled.')
|
||||||
|
|
||||||
|
if __name__=='__main__':
|
||||||
|
parser=argparse.ArgumentParser();parser.add_argument('--root',type=pathlib.Path,required=True)
|
||||||
|
main(parser.parse_args().root.resolve())
|
||||||
+17
-3
@@ -14,12 +14,12 @@ CLASSES={1:'Warrior',2:'Paladin',3:'Hunter',4:'Rogue',5:'Priest',7:'Shaman',8:'M
|
|||||||
|
|
||||||
def validate(command):
|
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 not isinstance(command,dict) or command.get('version')!=1 or command.get('backend')!='cmangos-classic': raise ValueError('Not a typed Classic command')
|
||||||
if command.get('kind') not in {'party','leave','recruit','intent','say'}:raise ValueError('Unsupported command kind')
|
if command.get('kind') not in {'party','leave','recruit','intent','say','reply-say','reply-whisper'}:raise ValueError('Unsupported command kind')
|
||||||
for key in ['guid','accountId','peerGuid','peerAccountId','expiresAt']:
|
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 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 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['peerGuid'] and (not command['peerAccountId'] or not re.fullmatch('[A-Za-z]{2,12}',command.get('peerName',''))):raise ValueError('Invalid peer identity')
|
||||||
if command['kind'] in {'party','recruit','say'} and not command['peerGuid']:raise ValueError('Peer identity required')
|
if command['kind'] in {'party','recruit','say','reply-say','reply-whisper'} and not command['peerGuid']:raise ValueError('Peer identity required')
|
||||||
if not isinstance(command.get('argument'),str) or len(command['argument'].encode())>1000:raise ValueError('Invalid argument')
|
if 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['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')
|
if command['kind']=='intent' and command['argument'] not in {'quest','explore','rest','trade','socialize'}:raise ValueError('Unsupported intention')
|
||||||
@@ -43,7 +43,7 @@ def main(root,base,env_file):
|
|||||||
req=urllib.request.Request(base.rstrip('/')+path,data=body,headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'})
|
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()
|
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})
|
def complete(id,ok,result):request('/api/bridge/ale/result',{'id':id,'ok':ok,'result':result})
|
||||||
last_snapshot=0;last_cleanup=0
|
last_snapshot=0;last_cleanup=0;last_outcomes=0
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
now=int(time.time())
|
now=int(time.time())
|
||||||
@@ -68,6 +68,19 @@ def main(root,base,env_file):
|
|||||||
if pairs:
|
if pairs:
|
||||||
a,b=pairs[(now//10)%len(pairs)];roster.update(socialA=a['name'],socialB=b['name'],zoneName=a.get('zoneName',''))
|
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
|
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')
|
headers,body=request('/api/bridge/ale/poll')
|
||||||
id=headers.get('X-AzerothAI-Command')
|
id=headers.get('X-AzerothAI-Command')
|
||||||
if id:
|
if id:
|
||||||
@@ -80,6 +93,7 @@ def main(root,base,env_file):
|
|||||||
complete(id,status=='completed',bytes.fromhex(result).decode());mysql('UPDATE azerothai_commands SET delivered=1 WHERE id='+quote(id))
|
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()")
|
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:
|
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
|
mysql("DELETE FROM azerothai_commands WHERE delivered=1 AND created_at<UNIX_TIMESTAMP()-604800");last_cleanup=now
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
# Do not print request objects, credentials, or native SQL arguments.
|
# Do not print request objects, credentials, or native SQL arguments.
|
||||||
|
|||||||
@@ -28,3 +28,22 @@ CREATE TABLE IF NOT EXISTS azerothai_snapshots (
|
|||||||
at BIGINT UNSIGNED NOT NULL,
|
at BIGINT UNSIGNED NOT NULL,
|
||||||
payload MEDIUMTEXT NOT NULL
|
payload MEDIUMTEXT NOT NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
CREATE TABLE IF NOT EXISTS azerothai_events (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
at BIGINT UNSIGNED NOT NULL,
|
||||||
|
kind VARCHAR(100) NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
delivered BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
INDEX delivery (delivered,id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
CREATE TABLE IF NOT EXISTS azerothai_counters (
|
||||||
|
guid INT UNSIGNED PRIMARY KEY,
|
||||||
|
quests BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
rewarded BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
lootEvents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
lootItems BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
lootedCopper BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
nativeDeaths BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
learnedSpells BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
resurrections BIGINT UNSIGNED NOT NULL DEFAULT 0
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|||||||
@@ -20,3 +20,11 @@ test('Classic peer commands bind both accounts and preserve literal speech',()=>
|
|||||||
assert.equal(command.peerGuid,8);assert.equal(command.peerAccountId,4);
|
assert.equal(command.peerGuid,8);assert.equal(command.peerAccountId,4);
|
||||||
assert.ok(command.expiresAt>Date.now()/1000);
|
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