Complete Classic telemetry, authenticated player replies and reversible cutover
Core tests / core (push) Successful in 27s

This commit is contained in:
Codex
2026-09-25 18:19:50 -07:00
parent d7d4e899f1
commit 6f9cdcb5a4
19 changed files with 394 additions and 17 deletions
+6
View File
@@ -9,3 +9,9 @@ export function classicCommand(kind:'party'|'leave'|'recruit'|'intent'|'say',bot
if(['party','recruit','say'].includes(kind)&&(!peer?.autonomy?.managed||!peer.autonomy.characterGuid||!peer.autonomy.accountId))throw new Error('Verified Classic peer required');
return JSON.stringify({version:1,backend:'cmangos-classic',kind,guid:identity.characterGuid,accountId:identity.accountId,name:bot.name,argument,peerGuid:peer?.autonomy?.characterGuid??0,peerAccountId:peer?.autonomy?.accountId??0,peerName:peer?.name??'',expiresAt:Math.floor(Date.now()/1000)+90});
}
export function classicPlayerReply(bot:Bot,request:{speakerGuid?:number;speakerAccountId?:number;speakerName:string;channel:'say'|'whisper'},reply:string){
if(!Number.isInteger(request.speakerGuid)||!request.speakerGuid||request.speakerGuid<1||!Number.isInteger(request.speakerAccountId)||!request.speakerAccountId||request.speakerAccountId<1||!/^[A-Za-z]{2,12}$/.test(request.speakerName))throw new Error('Verified Classic human identity required');
const command=JSON.parse(classicCommand('intent',bot,'socialize'));
return JSON.stringify({...command,kind:request.channel==='whisper'?'reply-whisper':'reply-say',argument:reply,peerGuid:request.speakerGuid,peerAccountId:request.speakerAccountId,peerName:request.speakerName});
}
+8
View File
@@ -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,
+2 -3
View File
@@ -1,4 +1,4 @@
import {classicMode,classicCommand} from './classic';
import {classicMode,classicCommand,classicPlayerReply} from './classic';
import {bondEvidence,socialEntry,communityTick} from './community';
import { z } from 'zod';
import { queueAleCommand } from './ale-store';
@@ -203,8 +203,7 @@ export async function runConversationTick(store:StateStore,db:D1Database,config:
reply=cleanReply(reply);
if(!reply)throw new Error('Conversation reply was empty');
if(classicMode(config)&&request.socialTurn===undefined)throw new Error('Classic player chat delivery is not enabled yet');
const source=classicMode(config)?classicCommand('say',bot,reply,byName(state,request.speakerName)):compileReply(bot.name,request.speakerName,reply,request.channel,request.socialTurn!==undefined);
const source=classicMode(config)?(request.socialTurn===undefined?classicPlayerReply(bot,request,reply):classicCommand('say',bot,reply,byName(state,request.speakerName))):compileReply(bot.name,request.speakerName,reply,request.channel,request.socialTurn!==undefined);
const command=await queueAleCommand(db,source);
await awaitDelivery(db,request,bot.id,reply,command.id);
await cleanupConversations(db);
+3 -2
View File
@@ -7,11 +7,12 @@ export function parseGM(raw:string){
}
const escapeXML=(s:string)=>s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&apos;');
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:'error'});
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');
+5 -2
View File
@@ -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.');}