Author SHA1 Message Date
Codex f91724ebfa Route dashboard settings and decisions through the Classic connector
Core tests / core (push) Successful in 27s
2026-09-25 18:32:31 -07:00
Codex 3019acb9c1 Keep Classic service GM account outside managed bot account prefix
Core tests / core (push) Successful in 27s
2026-09-25 18:26:11 -07:00
Codex 0d0edc6599 Handle Miniflare metadata during Classic cutover and preserve daily backups
Core tests / core (push) Successful in 28s
2026-09-25 18:23:08 -07:00
Codex 6f9cdcb5a4 Complete Classic telemetry, authenticated player replies and reversible cutover
Core tests / core (push) Successful in 27s
2026-09-25 18:19:50 -07:00
Codex d7d4e899f1 Fix Classic bot login, native commands and unattended activity
Core tests / core (push) Successful in 26s
2026-09-25 17:26:54 -07:00
Codex f5099cd338 Allow first-start cache initialization and restore durability before roster creation
Core tests / core (push) Successful in 30s
2026-09-25 17:04:15 -07:00
Codex 02d21abcbd Stage guarded Classic migration and native bot adapter
Core tests / core (push) Successful in 29s
2026-09-25 22:57:11 +02:00
Codex 58b2019015 Add isolated Classic data staging with integrity checks 2026-09-25 22:05:17 +02:00
Codex 1f9ad52d07 Allow telemetry cadence jitter in sampled bot hours
Core tests / core (push) Successful in 31s
2026-09-25 13:30:52 +08:00
Codex 502cb1e4fd Ground bot intentions in live game knowledge and verify outcomes
Core tests / core (push) Successful in 28s
2026-09-25 13:28:03 +08:00
Codex 20ccd0fced Use verified pathfinding retreat when bot flee is rejected
Core tests / core (push) Successful in 42s
2026-09-25 09:25:33 +08:00
Codex 9d1575b8c6 Batch authoritative snapshots and bound rejected retreat fallback 2026-09-25 09:24:51 +08:00
52 changed files with 1582 additions and 53 deletions
+33
View File
@@ -142,6 +142,37 @@ local function nearby_bot_names(player)
return table.concat(names,",")
end
-- Read only server knowledge; cache static quest text and bound prompt size.
local questKnowledge={}
local function decision_knowledge(player,p)
p.xp=player:GetXP()
local quests={}
for _,id in ipairs(p.quests) do
if #quests>=8 then break end
local q=questKnowledge[id]
if not q then
local row=WorldDBQuery('SELECT LogTitle,QuestLevel FROM quest_template WHERE ID='..id)
q=row and {title=string.sub(row:GetString(0) or '',1,100),level=row:GetInt32(1)} or {title='Quest '..id,level=0}
questKnowledge[id]=q
end
local ok,status=pcall(function() return player:GetQuestStatus(id) end)
local label=ok and (status==1 and 'ready to turn in' or status==3 and 'in progress' or status==5 and 'failed' or 'status '..tostring(status)) or 'status unavailable'
quests[#quests+1]=id..': '..q.title..' (level '..q.level..', '..label..')'
end
p.questBrief=table.concat(quests,'; ')
local threats={}
for _,enemy in pairs(player:GetUnfriendlyUnitsInRange(35) or {}) do
if not enemy:IsDead() then threats[#threats+1]=enemy end
end
table.sort(threats,function(a,b)return player:GetDistance(a)<player:GetDistance(b) end)
local descriptions={}
for i=1,math.min(4,#threats) do
local e=threats[i];local c=e:ToCreature()
descriptions[#descriptions+1]=string.sub(e:GetName(),1,70)..' level '..e:GetLevel()..' at '..math.floor(player:GetDistance(e))..' yards'..(c and c:GetRank()>0 and ' (non-normal rank)' or '')
end
p.threatBrief=table.concat(descriptions,'; ')
end
local function world_payload(player)
local p=player_payload(player)
local ai=player:IsPlayerbot() and player:GetBotAI() or nil
@@ -162,6 +193,8 @@ local function world_payload(player)
if AzerothAIExperienceSnapshot then for k,v in pairs(AzerothAIExperienceSnapshot(player)) do p[k]=v end end
if AzerothAITrainingSnapshot then for k,v in pairs(AzerothAITrainingSnapshot(player)) do p[k]=v end end
if AzerothAIReadinessSnapshot then for k,v in pairs(AzerothAIReadinessSnapshot(player)) do p[k]=v end end
local ok,err=pcall(decision_knowledge,player,p)
if not ok then p.questBrief=nil;p.threatBrief=nil end
p.nearbyBots=nearby_bot_names(player)
return p
end
+16
View File
@@ -174,6 +174,22 @@ local function sample()
r.dangerAt=now;r.retreatAt=now;r.action='retreat requested';r.actionAt=now
if p:IsCasting() then p:StopSpellCast() end
r.accepted=ai:DoSpecificAction('flee',true)
-- The bot action may reject fleeing when its target/context is missing.
-- Bound a native pathfinding attempt; never teleport or clear combat/threat.
if not r.accepted and not p:IsMoving() and now-(r.nativeFleeAt or 0)>=15 then
local threat=enemy
if not threat then for _,attacker in pairs(p:GetAttackers()) do threat=attacker;break end end
if threat then
local dx,dy=p:GetX()-threat:GetX(),p:GetY()-threat:GetY()
local distance=math.sqrt(dx*dx+dy*dy)
if distance>0.1 then
p:AttackStop()
p:MoveTo(7822,p:GetX()+20*dx/distance,p:GetY()+20*dy/distance,p:GetZ(),true)
r.nativeFleeAt=now
audit(p,'retreat-path-request','Playerbot flee rejected; 20-yard path away from attacker requested')
end
end
end
audit(p,'retreat-request', 'accepted='..tostring(r.accepted)..'; hp='..math.floor(hp)..'; dangerous='..tostring(overmatched)..'; attackers='..attackers..'; nearbySupport='..support)
if not r.retreat then r.retreat={at=now,map=p:GetMapId(),x=p:GetX(),y=p:GetY()} end
elseif avoid and now-(r.avoidAt or 0)>=8 then
+6
View File
@@ -1,6 +1,7 @@
import { env } from 'cloudflare:workers';
import { z } from 'zod';
import { requireAleOperator,rawAleEnabled } from '@/core/ale-auth';
import {runGM} from '@/core/gm';
import { compileAleOperatorRequest } from '@/core/ale';
import { queueAleCommand } from '@/core/ale-store';
import type { RuntimeConfig } from '@/core/types';
@@ -20,6 +21,11 @@ export async function POST(request:Request){
requireAleOperator(request,config);
const text=await request.text();if(text.length>16000)return Response.json({error:'Request too large'},{status:413});
const body=schema.parse(JSON.parse(text));
if(config.GAME_BACKEND==='cmangos-classic'){
if(body.kind!=='command')throw new Error('Classic supports native GM commands here; Lua/ALE methods belong to the Wrath backend.');
const result=await runGM(body.command,true,true,config);
return Response.json({ok:true,result});
}
const source=compileAleOperatorRequest(body,rawAleEnabled(config));
const queued=await queueAleCommand(env.DB!,source);
return Response.json({ok:true,command:queued},{status:202});
+10
View File
@@ -26,6 +26,7 @@ const STATEFUL_EVENTS=new Set([
'player.close-call',
'player.killed-by-creature',
'player.permanent-death',
'player.death',
'player.repop',
'player.resurrect'
]);
@@ -36,6 +37,11 @@ export async function POST(request:Request){
requireAleBridge(request,config);
const text=await request.text();if(text.length>40000)return Response.json({error:'Event too large'},{status:413});
const body=schema.parse(JSON.parse(text));
const eventId=typeof body.payload.nativeEventId==='string'?body.payload.nativeEventId.slice(0,100):null;
if(eventId){
await env.DB!.prepare('CREATE TABLE IF NOT EXISTS native_event_receipts (id TEXT PRIMARY KEY,at INTEGER)').run();
if(await env.DB!.prepare('SELECT id FROM native_event_receipts WHERE id=?').bind(eventId).first())return Response.json({ok:true,duplicate:true});
}
await recordAleEvent(env.DB!,body.kind,body.payload);
if((body.kind==='player.chat'||body.kind==='player.whisper')&&body.payload.isBot!==true){
@@ -54,6 +60,10 @@ export async function POST(request:Request){
if(zone)await execute(store,{type:'worldTrigger',kind:'zone-change',zone,playerName:String(body.payload.name??'').slice(0,40),detail:'Authoritative ALE human-player zone event'},config);
}
if(eventId){
await env.DB!.prepare('INSERT OR IGNORE INTO native_event_receipts VALUES (?,?)').bind(eventId,Math.floor(Date.now()/1000)).run();
await env.DB!.prepare('DELETE FROM native_event_receipts WHERE at<?').bind(Math.floor(Date.now()/1000)-8*86400).run();
}
return Response.json({ok:true});
}catch(e){
const message=e instanceof Error?e.message:'ALE event failure';
+3 -2
View File
@@ -1,3 +1,4 @@
import {classicMode} from '@/core/classic';
import { env } from 'cloudflare:workers';
import { requireAleBridge } from '@/core/ale-auth';
import { pollAleCommand } from '@/core/ale-store';
@@ -9,8 +10,8 @@ async function handlePoll(request:Request){
try{
requireAleBridge(request,config);
const command=await pollAleCommand(env.DB!);
if(!command)return new Response('return',{status:200,headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store'}});
return new Response(wrapAleCommand(command.id,command.source),{status:200,headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store','X-AzerothAI-Command':command.id}});
if(!command)return new Response(classicMode(config)?'null':'return',{status:200,headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store'}});
return new Response(classicMode(config)?command.source:wrapAleCommand(command.id,command.source),{status:200,headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store','X-AzerothAI-Command':command.id}});
}catch(e){
const message=e instanceof Error?e.message:'ALE bridge failure';
return Response.json({error:message},{status:message.startsWith('Unauthorized')?401:500});
+26
View File
@@ -0,0 +1,26 @@
import {env} from 'cloudflare:workers';
import {z} from 'zod';
import {requireAleBridge} from '@/core/ale-auth';
import {recordAleEvent} from '@/core/ale-store';
import {queueSocialEncounter} from '@/core/conversation-store';
import {D1Store} from '@/core/store';
import {applyAuthoritativeAleSnapshots} from '@/core/service';
import type {RuntimeConfig} from '@/core/types';
const schema=z.object({payloads:z.array(z.record(z.unknown())).min(1).max(64)}).strict();
export async function POST(request:Request){
try{
requireAleBridge(request,env as unknown as RuntimeConfig);
const text=await request.text();
if(text.length>512000)return Response.json({error:'Snapshot batch too large'},{status:413});
const {payloads}=schema.parse(JSON.parse(text));
const result=await applyAuthoritativeAleSnapshots(new D1Store(),payloads);
for(const payload of payloads){
await recordAleEvent(env.DB!,'player.snapshot',payload);
if(payload.roster===true)await queueSocialEncounter(env.DB!,payload);
}
return Response.json(result);
}catch(e){
const error=e instanceof Error?e.message:'Snapshot batch failure';
return Response.json({error},{status:error.startsWith('Unauthorized')?401:400});
}
}
+2 -1
View File
@@ -15,7 +15,8 @@ export async function GET(){
ok:true,
aleActiveRecently,
lastAleAt,
bridgeConfigured:!!(c.BRIDGE_URL&&c.BRIDGE_TOKEN),
gameBackend:c.GAME_BACKEND??'azerothcore',
bridgeConfigured:c.GAME_BACKEND==='cmangos-classic'?!!c.ALE_BRIDGE_TOKEN:!!(c.BRIDGE_URL&&c.BRIDGE_TOKEN),
aleConfigured:!!c.ALE_BRIDGE_TOKEN,
aleOperatorConfigured:!!c.ALE_OPERATOR_TOKEN,
aleRawEnabled:rawAleEnabled(c),
+10 -2
View File
@@ -6,10 +6,12 @@ import type {RuntimeConfig} from '@/core/types';
import {observeInsights,activityReason,reportWindow,summarize,DAY,type Cursor,type Evidence} from '@/core/realm-insights';
const recovery=z.object({id:z.number().int().positive(),guid:z.number().int().positive(),name:z.string().max(64),at:z.number().finite(),detail:z.string().max(255),status:z.enum(['pending','resumed','no_progress','unverified']).optional(),checkedAt:z.number().nonnegative().optional(),result:z.string().max(255).optional()});
const count=z.number().int().nonnegative();
const outcome=z.object({guid:count,name:z.string().max(64),quests:count,abandoned:count,lootEvents:count,lootItems:count,partyKills:count,rewarded:count,lootedCopper:count.default(0)});
const outcome=z.object({guid:count,name:z.string().max(64),quests:count,abandoned:count,lootEvents:count,lootItems:count,partyKills:count,rewarded:count,lootedCopper:count.default(0),nativeDeaths:count.optional()});
const measurement=z.number().finite().nonnegative();
const performance=z.object({at:measurement,enabled:z.boolean(),ticks:measurement,averageTickMs:measurement,rows:z.array(z.object({category:z.string().max(40),name:z.string().max(160),calls:measurement,totalMs:measurement,averageMs:measurement,maxMs:measurement,perTickMs:measurement})).max(20)});
async function ensure(){await env.DB!.batch([
env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_evaluation (id INTEGER PRIMARY KEY,started_at INTEGER)'),
env.DB!.prepare('INSERT OR IGNORE INTO insights_evaluation VALUES (1,?)').bind(Math.floor(Date.now()/1000)),
env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_native (id INTEGER PRIMARY KEY,payload TEXT)'),
env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_performance (at INTEGER PRIMARY KEY,payload TEXT)'),
env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_cursor (id INTEGER PRIMARY KEY,payload TEXT)'),
@@ -38,8 +40,14 @@ export async function GET(){
const totals:Record<string,number>={};for(const e of aggregates.results)totals[e.kind]=(totals[e.kind]??0)+e.amount;
const byBot=s.bots.filter(b=>b.autonomy?.managed&&b.status==='online').map(b=>({name:b.name,zone:b.zone,grouped:!!b.world?.groupId&&now-b.world.at<120,groupBots:(b.world?.groupNames??[]).filter(n=>s.bots.some(o=>o.name===n&&o.autonomy?.managed)),totals:Object.fromEntries([...counterKeys,'groupedSeconds','nearPartySeconds','sharedQuests'].map(k=>[k,aggregates.results.filter(e=>e.name===b.name&&e.kind===k).reduce((sum,e)=>sum+e.amount,0)]))}));
const publicRecoveries=attempts.map((r:Recovery)=>({name:r.name,at:r.at,detail:r.detail,status:r.status??'unverified',checkedAt:r.checkedAt??null,result:r.result??'No verification recorded'}));
const evaluationRow=await env.DB!.prepare('SELECT started_at FROM insights_evaluation WHERE id=1').first<{started_at:number}>();
const evaluationStart=Math.max(now-DAY,evaluationRow?.started_at??now);
const evaluationRows=await env.DB!.prepare("SELECT json_extract(payload,'$.kind') AS kind,SUM(json_extract(payload,'$.amount')) AS amount FROM insights_evidence WHERE at>=? GROUP BY kind").bind(evaluationStart).all<{kind:string;amount:number}>();
const evaluationTotals=Object.fromEntries(evaluationRows.results.map(r=>[r.kind,r.amount]));
const hours=(evaluationTotals.onlineSeconds??0)/3600;
const evaluation={since:evaluationStart,onlineBotHours:hours,activeBotHours:(evaluationTotals.activeSeconds??0)/3600,deaths:evaluationTotals.nativeDeaths??0,deathsPerBotHour:hours?(evaluationTotals.nativeDeaths??0)/hours:null,questTurnIns:evaluationTotals.rewarded??0,questTurnInsPerBotHour:hours?(evaluationTotals.rewarded??0)/hours:null,intentsObserved:evaluationTotals.intentObserved??0,intentsUnverified:evaluationTotals.intentUnverified??0,intentsInterrupted:evaluationTotals.intentInterrupted??0,nearbyPartyHours:(evaluationTotals.nearPartySeconds??0)/3600};
const attention=attentionItems(s,now,cursor?.at??null,native?.at??null,perf,attempts,health);
return Response.json({attention,outcomes:{at:native?.at??null,startedAt:native?.startedAt??null,windowStart:Math.max(now-DAY,native?.startedAt??now),totals,bots:byBot,recent:recent.slice(0,60)},recoveryResults:publicRecoveries,performance:{latest:perf,history:perfHistory.map(p=>({at:p.at,averageTickMs:p.averageTickMs,ticks:p.ticks})).reverse()},collectedAt:cursor?.at??null,startedAt:cursor?.startedAt??null,nextReportAt:reportWindow(now)+DAY,current:await report(reportWindow(now),now+1,cursor?.startedAt??now),reports:history.results.map(r=>JSON.parse(r.payload)),activity:s.bots.filter(b=>b.autonomy?.managed&&b.status==='online').map(b=>({name:b.name,zone:b.zone,action:b.lastDecision?.action??'unknown',observedAt:b.world?.at??null,stationarySince:b.world?.stationarySince??null,lastProgressAt:cursor?.bots[b.id]?.lastProgressAt??null,...activityReason(b,now),recoveries:attempts.filter(r=>r.guid===b.autonomy?.characterGuid).slice(0,3).map(({at,detail})=>({at,detail}))}))},{headers:{'Cache-Control':'no-store'}});
return Response.json({evaluation,attention,outcomes:{at:native?.at??null,startedAt:native?.startedAt??null,windowStart:Math.max(now-DAY,native?.startedAt??now),totals,bots:byBot,recent:recent.slice(0,60)},recoveryResults:publicRecoveries,performance:{latest:perf,history:perfHistory.map(p=>({at:p.at,averageTickMs:p.averageTickMs,ticks:p.ticks})).reverse()},collectedAt:cursor?.at??null,startedAt:cursor?.startedAt??null,nextReportAt:reportWindow(now)+DAY,current:await report(reportWindow(now),now+1,cursor?.startedAt??now),reports:history.results.map(r=>JSON.parse(r.payload)),activity:s.bots.filter(b=>b.autonomy?.managed&&b.status==='online').map(b=>({name:b.name,zone:b.zone,action:b.lastDecision?.action??'unknown',observedAt:b.world?.at??null,stationarySince:b.world?.stationarySince??null,lastProgressAt:cursor?.bots[b.id]?.lastProgressAt??null,...activityReason(b,now),intentResults:(b.autonomy?.intentChecks??[]).slice(-3).map(({action,status,detail,startedAt,checkedAt})=>({action,status,detail,startedAt,checkedAt})),recoveries:attempts.filter(r=>r.guid===b.autonomy?.characterGuid).slice(0,3).map(({at,detail})=>({at,detail}))}))},{headers:{'Cache-Control':'no-store'}});
}
export async function POST(request:Request){
const token=(env as unknown as RuntimeConfig).AUTONOMY_TOKEN;if(!token||request.headers.get('authorization')!==`Bearer ${token}`)return Response.json({error:'Unauthorized'},{status:401});
+7 -7
View File
File diff suppressed because one or more lines are too long
+5 -4
View File
@@ -3,10 +3,10 @@ import {useEffect,useState} from 'react';
import {Input} from '@/components/ui/input';
import type {summarize} from '@/core/realm-insights';
type Report=ReturnType<typeof summarize>;
type Activity={name:string;zone:string;action:string;label:string;detail:string;observedAt:number|null;lastProgressAt:number|null;recoveries:{at:number;detail:string}[]};
type Insights={collectedAt:number|null;startedAt:number|null;nextReportAt:number;current:Report;reports:Report[];activity:Activity[]};
type Activity={name:string;zone:string;action:string;label:string;detail:string;observedAt:number|null;lastProgressAt:number|null;intentResults?:{action:string;status:string;detail:string}[];recoveries:{at:number;detail:string}[]};
type Insights={evaluation?:{since:number;onlineBotHours:number;activeBotHours:number;deaths:number;deathsPerBotHour:number|null;questTurnInsPerBotHour:number|null;intentsObserved:number;intentsUnverified:number;intentsInterrupted:number;nearbyPartyHours:number};collectedAt:number|null;startedAt:number|null;nextReportAt:number;current:Report;reports:Report[];activity:Activity[]};
const stamp=(at:number|null)=>at?new Date(at*1000).toLocaleString():'Not yet observed';
const metrics:Record<string,string>={levels:'Levels gained',deaths:'Verified deaths',arrivals:'First arrivals',conversations:'Bot exchanges',bonds:'New positive bonds',parties:'Party invitations',recruits:'Guild admissions',guild:'Guild goal progress',recoveries:'Recovery attempts'};
const metrics:Record<string,string>={levels:'Levels gained',deaths:'Permanent deaths',nativeDeaths:'Native deaths (all modes)',arrivals:'First arrivals',conversations:'Bot exchanges',bonds:'New positive bonds',parties:'Party invitations',recruits:'Guild admissions',guild:'Guild goal progress',recoveries:'Recovery attempts'};
export function RealmInsights({mode,initialQuery=''}:{mode:string;initialQuery?:string}){
const [data,setData]=useState<Insights|null>(null),[error,setError]=useState(''),[selected,setSelected]=useState('current'),[query,setQuery]=useState(initialQuery),[attention,setAttention]=useState(false);
useEffect(()=>{let stopped=false;const poll=async()=>{try{const r=await fetch('/api/realm-insights');if(!r.ok)throw new Error('Insights temporarily unavailable');const d=await r.json() as Insights;if(!stopped){setData(d);setError('');}}catch(e){if(!stopped)setError(e instanceof Error?e.message:'Unable to load insights');}};void poll();const timer=setInterval(poll,15000);return()=>{stopped=true;clearInterval(timer);};},[]);
@@ -14,10 +14,11 @@ export function RealmInsights({mode,initialQuery=''}:{mode:string;initialQuery?:
const report=selected==='current'?data.current:data.reports.find(r=>String(r.start)===selected)??data.current;
const rows=data.activity.filter(b=>`${b.name} ${b.zone} ${b.label}`.toLowerCase().includes(query.toLowerCase())&&(!attention||['Possible stall','Telemetry delayed'].includes(b.label)));
return <><p className="fineprint">Last collection: {stamp(data.collectedAt)} · Refreshes every 15 seconds.</p>{error&&<p role="alert" className="notice">{error}; showing the last available observations.</p>}{(!data.collectedAt||Date.now()/1000-data.collectedAt>180)&&<p role="status" className="notice">Collection is pending or delayed. Report totals may be incomplete.</p>}
{data.evaluation&&<section className="panel" style={{marginBottom:20}}><div className="panel-head"><h2>Gameplay evaluation</h2></div><div className="panel-body"><p>Since {stamp(data.evaluation.since)} · {data.evaluation.onlineBotHours.toFixed(1)} observed bot-hours · {data.evaluation.activeBotHours.toFixed(1)} active bot-hours</p><p>{data.evaluation.deaths} native deaths · {data.evaluation.deathsPerBotHour?.toFixed(2)??'—'} deaths / bot-hour · {data.evaluation.questTurnInsPerBotHour?.toFixed(2)??'—'} quest turn-ins / bot-hour</p><p>{data.evaluation.intentsObserved} intentions with observed results · {data.evaluation.intentsUnverified} without verified results · {data.evaluation.intentsInterrupted} interrupted</p><p className="fineprint">Fresh consecutive samples only; collection gaps are excluded. Activity includes movement, combat and casting. Results after an intention show correlation, not proof that the model caused them. Party proximity: {data.evaluation.nearbyPartyHours.toFixed(1)} bot-hours.</p></div></section>}
{mode==='Nightly Report'?<>
<section className="panel"><div className="panel-body"><label className="field">Report period<select value={selected} onChange={e=>setSelected(e.target.value)}><option value="current">Today · in progress</option>{data.reports.map(r=><option key={r.start} value={r.start}>{stamp(r.start)} — {stamp(r.end)}</option>)}</select></label><p>{stamp(report.start)} — {selected==='current'?'now':stamp(report.end)}</p><p className="fineprint">Reports close daily at 06:00 UTC. Next close: {stamp(data.nextReportAt)}. Reports are kept for 30 days.</p><p className="fineprint">{report.partial?'Partial period · ':''}Observations since {stamp(report.observedSince)} · {report.samples} minute samples. Gaps can miss activity; these are observed changes, not a complete game log.</p></div></section>
<div className="stats" style={{marginTop:20}}>{Object.entries(metrics).map(([key,label])=><div className="panel stat" key={key}><div className="stat-label">{label}</div><div className="stat-value">{report.totals[key]??0}</div></div>)}</div>
<section className="panel"><div className="panel-head"><h2>Realm highlights</h2></div><div className="panel-body">{report.highlights.length?report.highlights.map(e=><div className="memory" key={e.id}><strong>{e.name}</strong><p>{e.detail}</p><small>{stamp(e.at)}</small></div>):<p className="empty-note">No changes recorded in this period yet.</p>}<p className="fineprint">Exchanges count confirmed bot replies, excluding synthetic openers and private player messages. Positive bonds need not be mutual friendships. Arrivals mean first observed online, not necessarily newly created. Recovery attempts do not prove recovery succeeded.</p></div></section>
</>:<><section className="panel"><div className="panel-body"><Input aria-label="Search bot activity" placeholder="Search by name, zone, or activity" value={query} onChange={e=>setQuery(e.target.value)}/><label style={{display:'flex',gap:8,marginTop:16}}><input type="checkbox" checked={attention} onChange={e=>setAttention(e.target.checked)}/>Show possible stalls and delayed observations</label><p className="fineprint">{rows.length} of {data.activity.length} online residents. Progress means observed movement, level gain, or health change. Intentions describe plans, not completed game actions.</p></div></section><div style={{display:'grid',gridTemplateColumns:'repeat(auto-fit,minmax(min(100%,320px),1fr))',gap:16,marginTop:20}}>{rows.map(b=><section className="panel" key={b.name}><div className="panel-head"><h2>{b.name}</h2><span className="tag">{b.label}</span></div><div className="panel-body"><p>{b.zone} · Intent: {b.action}</p><p style={{marginTop:12}}>{b.detail}</p><p className="fineprint">Game observation: {stamp(b.observedAt)}<br/>Last observed progress: {stamp(b.lastProgressAt)}</p><details><summary>Recent recovery attempts ({b.recoveries.length})</summary>{b.recoveries.length?b.recoveries.map((r,i)=><p className="fineprint" key={i}>{stamp(r.at)} · {r.detail}</p>):<p className="fineprint">No retained attempts. Recovery requires ten minutes without native progress, safe conditions, and an expired 30-minute cooldown.</p>}</details></div></section>)}</div>{!rows.length&&<p className="empty-note">No residents match this filter.</p>}</>}
</>:<><section className="panel"><div className="panel-body"><Input aria-label="Search bot activity" placeholder="Search by name, zone, or activity" value={query} onChange={e=>setQuery(e.target.value)}/><label style={{display:'flex',gap:8,marginTop:16}}><input type="checkbox" checked={attention} onChange={e=>setAttention(e.target.checked)}/>Show possible stalls and delayed observations</label><p className="fineprint">{rows.length} of {data.activity.length} online residents. Progress means observed movement, level gain, or health change. Intentions describe plans, not completed game actions.</p></div></section><div style={{display:'grid',gridTemplateColumns:'repeat(auto-fit,minmax(min(100%,320px),1fr))',gap:16,marginTop:20}}>{rows.map(b=><section className="panel" key={b.name}><div className="panel-head"><h2>{b.name}</h2><span className="tag">{b.label}</span></div><div className="panel-body"><p>{b.zone} · Intent: {b.action}</p><p style={{marginTop:12}}>{b.detail}</p><p className="fineprint">Game observation: {stamp(b.observedAt)}<br/>Last observed progress: {stamp(b.lastProgressAt)}</p>{b.intentResults?.map((r,i)=><p className="fineprint" key={'intent'+i}><strong>{r.action} · {r.status}</strong><br/>{r.detail}</p>)}<details><summary>Recent recovery attempts ({b.recoveries.length})</summary>{b.recoveries.length?b.recoveries.map((r,i)=><p className="fineprint" key={i}>{stamp(r.at)} · {r.detail}</p>):<p className="fineprint">No retained attempts. Recovery requires ten minutes without native progress, safe conditions, and an expired 30-minute cooldown.</p>}</details></div></section>)}</div>{!rows.length&&<p className="empty-note">No residents match this filter.</p>}</>}
</>;
}
+31 -6
View File
@@ -1,3 +1,6 @@
import {classicMode,classicCommand} from './classic';
import {startIntentCheck} from './intent-evidence';
import {intentCompanions,partyIntentBusy,recordPartyIntent,compileCommunityAction} from './community';
import {DeterministicProvider,providerForBot} from './llm';
import {luaString} from './ale';
import {queueAleCommand,recentAleEvents} from './ale-store';
@@ -26,7 +29,7 @@ function latestObservationFor(bot:Bot,events:Awaited<ReturnType<typeof recentAle
});
}
function observationFrom(bot:Bot,event:Awaited<ReturnType<typeof recentAleEvents>>[number],events:Awaited<ReturnType<typeof recentAleEvents>>,hardcore=true):Observation{
export function observationFrom(bot:Bot,event:Awaited<ReturnType<typeof recentAleEvents>>[number],events:Awaited<ReturnType<typeof recentAleEvents>>,hardcore=true):Observation{
const p=payloadObject(event.payload);
const zone=typeof p.zoneName==='string'&&p.zoneName.trim()?p.zoneName.trim().slice(0,120):bot.zone;
const level=typeof p.level==='number'&&Number.isInteger(p.level)&&p.level>0&&p.level<=100?p.level:bot.level;
@@ -38,11 +41,23 @@ function observationFrom(bot:Bot,event:Awaited<ReturnType<typeof recentAleEvents
zone,
level,
alive:bot.status!=='dead',
availableActions:[...bot.personality.activities],
summary:`Realm death rules: ${hardcore?'permanent death':'normal resurrection; recover and resume progression after death'}. Authoritative live Playerbot observation in ${zone} at level ${level}. Recent server events: ${recent.join(', ')||'snapshot'}.`
availableActions:availableIntentActions(bot),
summary:`Realm death rules: ${hardcore?'permanent death':'normal resurrection; recover and resume progression after death'}. Authoritative live Playerbot observation in ${zone} at level ${level}. Recent server events: ${recent.join(', ')||'snapshot'}. ${decisionContext(bot)}`
};
}
export function availableIntentActions(bot:Bot){
const actions=[...bot.personality.activities],w=bot.world;
if(w&&Date.now()/1000-w.at>=0&&Date.now()/1000-w.at<90&&(w.healthPct??0)>=95&&(w.manaPct===undefined||w.manaPct>=65)&&actions.some(a=>a!=='rest'))return actions.filter(a=>a!=='rest');
return actions;
}
export function decisionContext(bot:Bot){
const w=bot.world;if(!w||Date.now()/1000-w.at>90)return 'Live condition details unavailable; do not invent them.';
const ready=w.readinessAt&&Date.now()/1000-w.readinessAt<180;
const last=bot.autonomy?.intentChecks?.filter(c=>c.status!=='pending').slice(-2).map(c=>`${c.action}: ${c.status}; ${c.detail}`).join(' | ');
return JSON.stringify({health:w.healthPct,mana:w.manaPct,dead:w.isDead||w.isGhost,inCombat:w.inCombat,nearbyThreats:w.threatBrief??'unknown',activeQuestLog:w.questBrief??'unknown',supplies:ready?{food:w.foodCount,drinks:w.drinkCount,durability:w.minDurabilityPct,brokenItems:w.brokenItems}:null,party:w.groupNames,nearby:w.nearby,recentOutcomes:last||'No outcome verified yet',capabilities:'quest/explore request native progression; trade requests native supplies/repair; socialize can invite an eligible nearby companion. Never claim an action completed until observed.'});
}
function strategy(ai:string,change:string){
return `${ai}:ChangeStrategy(${luaString(change)},1)`;
}
@@ -55,6 +70,7 @@ export function compileLivePlayerbotIntent(botName:string,decision:Decision){
`if not ${p}:IsPlayerbot() then error("Managed character is not controlled by PlayerbotAI") end`,
`local ${ai}=${p}:GetBotAI()`,
`if not ${ai} then error("PlayerbotAI unavailable") end`,
`if ${p}:IsDead() then return "Native resurrection takes priority" end`,
`if ${ai}:HasGameClientMaster() then return "Human control preserved" end`,
`if ${p}:HasUnitState(0x00000100) then return "Native taxi flight takes priority" end`,
`if AzerothAISurvivalOverride and AzerothAISurvivalOverride(${p}) then return "Native survival action takes priority" end`,
@@ -82,7 +98,7 @@ export function compileLivePlayerbotIntent(botName:string,decision:Decision){
case 'trade':
clearStay();
body.push(strategy(ai,'+rpg'));
body.push(`${ai}:DoSpecificAction("rpg sell",true,"")`);
body.push(`${ai}:DoSpecificAction("rpg sell",true,"");${ai}:DoSpecificAction("rpg buy",true,"");${ai}:DoSpecificAction("repair",true,"")`);
body.push(`${ai}:DoNextAction(false)`);
break;
case 'rest':
@@ -112,7 +128,7 @@ export async function runLiveAutonomyTick(
const events=await recentAleEvents(db,400);
const candidates=s.bots
.filter(b=>b.status!=='dead'&&b.status!=='retired'&&b.autonomy?.managed===true)
.filter(b=>b.status==='online'&&b.autonomy?.managed===true&&!b.world?.isDead&&!b.world?.isGhost&&!b.world?.humanControlled&&!b.world?.taxi)
.map(bot=>({bot,obs:latestObservationFor(bot,events)}))
.filter((x):x is {bot:Bot;obs:NonNullable<typeof x.obs>}=>!!x.obs)
.filter(x=>now-Date.parse(x.obs.at)<=OBSERVATION_MAX_AGE_MS)
@@ -147,6 +163,8 @@ export async function runLiveAutonomyTick(
const observation=observationFrom(bot,pick.obs,events,s.settings.hardcore.enabled);
const guild=s.community?.guilds.find(g=>g.id===bot.world?.guildId);
if(guild)observation.summary+=` Your guild ${guild.name} works toward ${guild.goal}; confirmed progress ${guild.progress}/${guild.target}. Favor helpful actions toward that goal when safe.`;
const companions=intentCompanions(s,bot).slice(0,3);
observation.summary+=' Eligible nearby party candidates: '+(companions.map(p=>`${p.name} (level ${p.level}, ${p.world?.combatRole??p.role})`).join(', ')||'none; do not invent a companion or promise a party')+'.';
const brain=providerForBot(bot,s.settings,config);
let decision:Decision;
let usedFallback=false;
@@ -168,8 +186,14 @@ export async function runLiveAutonomyTick(
}
bot=currentBot;
if(usedFallback)realmEvent(s,'system',`${bot.name} LLM route unavailable; used deterministic fallback for this heartbeat.`,bot.id);
const source=compileLivePlayerbotIntent(bot.name,decision);
// Recheck eligibility after inference; native guards recheck once more at execution.
let peer=decision.action==='socialize'?intentCompanions(s,bot)[0]:undefined;
if(peer&&await partyIntentBusy(db,bot,peer))peer=undefined;
const source=classicMode(config)?(peer?classicCommand('party',bot,'',peer):classicCommand('intent',bot,decision.action)):(peer?compileCommunityAction('party',bot,peer):compileLivePlayerbotIntent(bot.name,decision));
const queued=await queueAleCommand(db,source);
if(peer)await recordPartyIntent(db,s,queued.id,bot,peer);
const check=startIntentCheck(bot,decision,queued.id,Date.now()/1000,peer?.name);
bot.lastDecision=decision;
bot.decisions++;
@@ -182,6 +206,7 @@ export async function runLiveAutonomyTick(
observedAt:newerObservation?bot.autonomy?.observedAt:pick.obs.at,
lastIntentAt:new Date(now).toISOString(),
nextIntentAt:new Date(now+nextIntervalMs(bot)).toISOString(),
intentChecks:[...(bot.autonomy?.intentChecks??[]),...(check?[check]:[])].slice(-12),
lastAleCommandId:queued.id
};
realmEvent(s,'system',`${bot.name} brain route: ${brain.route.provider} · ${brain.route.model} · autonomous live heartbeat.`,bot.id);
+17
View File
@@ -0,0 +1,17 @@
import type {Bot,RuntimeConfig} from './types';
export const classicMode=(config:RuntimeConfig)=>config.GAME_BACKEND==='cmangos-classic';
/** Typed native actions only; the Classic bridge never evaluates Lua. */
export function classicCommand(kind:'party'|'leave'|'recruit'|'intent'|'say',bot:Bot,argument='',peer?:Bot){
const identity=bot.autonomy;
if(!identity?.managed||!Number.isInteger(identity.characterGuid)||!identity.characterGuid||identity.characterGuid<1||!Number.isInteger(identity.accountId)||!identity.accountId||identity.accountId<1)throw new Error('Verified Classic character identity required');
if(['party','recruit','say'].includes(kind)&&(!peer?.autonomy?.managed||!peer.autonomy.characterGuid||!peer.autonomy.accountId))throw new Error('Verified Classic peer required');
return JSON.stringify({version:1,backend:'cmangos-classic',kind,guid:identity.characterGuid,accountId:identity.accountId,name:bot.name,argument,peerGuid:peer?.autonomy?.characterGuid??0,peerAccountId:peer?.autonomy?.accountId??0,peerName:peer?.name??'',expiresAt:Math.floor(Date.now()/1000)+90});
}
export function classicPlayerReply(bot:Bot,request:{speakerGuid?:number;speakerAccountId?:number;speakerName:string;channel:'say'|'whisper'},reply:string){
if(!Number.isInteger(request.speakerGuid)||!request.speakerGuid||request.speakerGuid<1||!Number.isInteger(request.speakerAccountId)||!request.speakerAccountId||request.speakerAccountId<1||!/^[A-Za-z]{2,12}$/.test(request.speakerName))throw new Error('Verified Classic human identity required');
const command=JSON.parse(classicCommand('intent',bot,'socialize'));
return JSON.stringify({...command,kind:request.channel==='whisper'?'reply-whisper':'reply-say',argument:reply,peerGuid:request.speakerGuid,peerAccountId:request.speakerAccountId,peerName:request.speakerName});
}
+24 -5
View File
@@ -1,4 +1,6 @@
import type {Bot,State,StateStore,SocialEntry} from './types';
import {classicMode,classicCommand} from './classic';
import {observeIntentChecks} from './intent-evidence';
import type {Bot,State,StateStore,SocialEntry,RuntimeConfig} from './types';
import {luaString} from './ale';
import {queueAleCommand} from './ale-store';
@@ -24,7 +26,8 @@ export function observePresence(s:State,b:Bot,p:Record<string,unknown>){
if(typeof p.team!=='number'||typeof p.x!=='number'||typeof p.y!=='number')return;
const old=b.world;
const stationary=old&&old.mapId===p.mapId&&Math.hypot(old.x-p.x,old.y-p.y)<3;
b.world={trainingPending:typeof p.trainingPending==='number'?p.trainingPending:undefined,trainingLevel:typeof p.trainingLevel==='number'?p.trainingLevel:undefined,trainingTrainer:typeof p.trainingTrainer==='string'?p.trainingTrainer:undefined,trainingStatus:typeof p.trainingStatus==='string'?p.trainingStatus:undefined,trainingAt:typeof p.trainingAt==='number'?p.trainingAt:undefined,trainingLearned:typeof p.trainingLearned==='number'?p.trainingLearned:undefined,lastTrainingAt:typeof p.lastTrainingAt==='number'?p.lastTrainingAt:undefined,minDurabilityPct:typeof p.minDurabilityPct==='number'?p.minDurabilityPct:undefined,survivalSince:typeof p.survivalSince==='number'?p.survivalSince:undefined,isDead:typeof p.isDead==='boolean'?p.isDead:undefined,isGhost:typeof p.isGhost==='boolean'?p.isGhost:undefined,manaPct:typeof p.manaPct==='number'?p.manaPct:undefined,leaderName:typeof p.leaderName==='string'?p.leaderName:undefined,brokenItems:typeof p.brokenItems==='number'?p.brokenItems:undefined,freeTalents:typeof p.freeTalents==='number'?p.freeTalents:undefined,foodCount:typeof p.foodCount==='number'?p.foodCount:undefined,drinkCount:typeof p.drinkCount==='number'?p.drinkCount:undefined,readinessAt:typeof p.readinessAt==='number'?p.readinessAt:undefined,deathMemoryId:typeof p.deathMemoryId==='number'?p.deathMemoryId:undefined,deathMemoryAt:typeof p.deathMemoryAt==='number'?p.deathMemoryAt:undefined,deathMemoryKiller:typeof p.deathMemoryKiller==='string'?p.deathMemoryKiller:undefined,deathMemoryMap:typeof p.deathMemoryMap==='number'?p.deathMemoryMap:undefined,deathMemoryX:typeof p.deathMemoryX==='number'?p.deathMemoryX:undefined,deathMemoryY:typeof p.deathMemoryY==='number'?p.deathMemoryY:undefined,deathMemoryLevel:typeof p.deathMemoryLevel==='number'?p.deathMemoryLevel:undefined,quests:Array.isArray(p.quests)?p.quests.filter((q):q is number=>typeof q==='number'&&q>0).slice(0,25):undefined,dangerAt:typeof p.dangerAt==='number'?p.dangerAt:old?.dangerAt,survivalAction:typeof p.survivalAction==='string'?p.survivalAction:undefined,survivalAt:typeof p.survivalAt==='number'?p.survivalAt:undefined,combatRole:typeof p.combatRole==='string'?p.combatRole:undefined,casting:p.casting===true,taxi:p.taxi===true,humanControlled:p.humanControlled===true,movementType:typeof p.movementType==='number'?p.movementType:undefined,healthPct:typeof p.healthPct==='number'?p.healthPct:undefined,at,team:p.team,guildId:Number(p.guildId)||0,guildName:String(p.guildName||''),guildRank:Number(p.guildRank)||0,groupId:String(p.groupId||''),groupNames:String(p.groupNames||'').split(',').filter(Boolean),nearby:String(p.nearbyBots||'').split(',').filter(Boolean),inCombat:p.inCombat===true,x:p.x,y:p.y,mapId:Number(p.mapId)||0,stationarySince:stationary?(old.stationarySince??old.at):at};
b.world={xp:typeof p.xp==='number'&&Number.isFinite(p.xp)?p.xp:undefined,questBrief:typeof p.questBrief==='string'?p.questBrief.slice(0,1800):undefined,threatBrief:typeof p.threatBrief==='string'?p.threatBrief.slice(0,600):undefined,trainingPending:typeof p.trainingPending==='number'?p.trainingPending:undefined,trainingLevel:typeof p.trainingLevel==='number'?p.trainingLevel:undefined,trainingTrainer:typeof p.trainingTrainer==='string'?p.trainingTrainer:undefined,trainingStatus:typeof p.trainingStatus==='string'?p.trainingStatus:undefined,trainingAt:typeof p.trainingAt==='number'?p.trainingAt:undefined,trainingLearned:typeof p.trainingLearned==='number'?p.trainingLearned:undefined,lastTrainingAt:typeof p.lastTrainingAt==='number'?p.lastTrainingAt:undefined,minDurabilityPct:typeof p.minDurabilityPct==='number'?p.minDurabilityPct:undefined,survivalSince:typeof p.survivalSince==='number'?p.survivalSince:undefined,isDead:typeof p.isDead==='boolean'?p.isDead:undefined,isGhost:typeof p.isGhost==='boolean'?p.isGhost:undefined,manaPct:typeof p.manaPct==='number'?p.manaPct:undefined,leaderName:typeof p.leaderName==='string'?p.leaderName:undefined,brokenItems:typeof p.brokenItems==='number'?p.brokenItems:undefined,freeTalents:typeof p.freeTalents==='number'?p.freeTalents:undefined,foodCount:typeof p.foodCount==='number'?p.foodCount:undefined,drinkCount:typeof p.drinkCount==='number'?p.drinkCount:undefined,readinessAt:typeof p.readinessAt==='number'?p.readinessAt:undefined,deathMemoryId:typeof p.deathMemoryId==='number'?p.deathMemoryId:undefined,deathMemoryAt:typeof p.deathMemoryAt==='number'?p.deathMemoryAt:undefined,deathMemoryKiller:typeof p.deathMemoryKiller==='string'?p.deathMemoryKiller:undefined,deathMemoryMap:typeof p.deathMemoryMap==='number'?p.deathMemoryMap:undefined,deathMemoryX:typeof p.deathMemoryX==='number'?p.deathMemoryX:undefined,deathMemoryY:typeof p.deathMemoryY==='number'?p.deathMemoryY:undefined,deathMemoryLevel:typeof p.deathMemoryLevel==='number'?p.deathMemoryLevel:undefined,quests:Array.isArray(p.quests)?p.quests.filter((q):q is number=>typeof q==='number'&&q>0).slice(0,25):undefined,dangerAt:typeof p.dangerAt==='number'?p.dangerAt:old?.dangerAt,survivalAction:typeof p.survivalAction==='string'?p.survivalAction:undefined,survivalAt:typeof p.survivalAt==='number'?p.survivalAt:undefined,combatRole:typeof p.combatRole==='string'?p.combatRole:undefined,casting:p.casting===true,taxi:p.taxi===true,humanControlled:p.humanControlled===true,movementType:typeof p.movementType==='number'?p.movementType:undefined,healthPct:typeof p.healthPct==='number'?p.healthPct:undefined,at,team:p.team,guildId:Number(p.guildId)||0,guildName:String(p.guildName||''),guildRank:Number(p.guildRank)||0,groupId:String(p.groupId||''),groupNames:String(p.groupNames||'').split(',').filter(Boolean),nearby:String(p.nearbyBots||'').split(',').filter(Boolean),inCombat:p.inCombat===true,x:p.x,y:p.y,mapId:Number(p.mapId)||0,stationarySince:stationary?(old.stationarySince??old.at):at};
observeIntentChecks(b);
if(b.world.deathMemoryId && b.world.deathMemoryId>(b.deathMemorySeenId??0)){
b.deathMemorySeenId=b.world.deathMemoryId;
b.memories.push({id:`native-death:${b.world.deathMemoryId}`,text:`I died to ${b.world.deathMemoryKiller??'an unknown enemy'} at level ${b.world.deathMemoryLevel??b.level}, map ${b.world.deathMemoryMap}, near (${Math.round(b.world.deathMemoryX??0)}, ${Math.round(b.world.deathMemoryY??0)}). Approach similar enemies there cautiously; recover before fighting again.`,importance:10,source:'observation',at:new Date((b.world.deathMemoryAt??at)*1000).toISOString()});
@@ -58,13 +61,29 @@ export function companionScore(a:Bot,b:Bot){
const shared=(a.world?.quests??[]).filter(q=>b.world?.quests?.includes(q)).length;
return Math.min(40,shared*15)+(recentDanger(b)?30:0)+roleBonus+Math.min(20,Math.max(0,affinity(a,b)))+Math.max(0,10-Math.abs(a.level-b.level));
}
const fresh=(b:Bot)=>b.status==='online'&&b.autonomy?.managed&&b.world&&Date.now()/1000-b.world.at<90&&!b.world.inCombat&&!b.world.humanControlled&&!b.world.taxi&&!b.world.casting;
const fresh=(b:Bot)=>b.status==='online'&&b.autonomy?.managed&&b.world&&Date.now()/1000-b.world.at<90&&!b.world.isDead&&!b.world.isGhost&&!b.world.inCombat&&!b.world.humanControlled&&!b.world.taxi&&!b.world.casting;
export function partyDecision(a:Bot,b:Bot){
if(!fresh(a)||!fresh(b)||a.id===b.id||a.world!.team!==b.world!.team||Math.abs(a.level-b.level)>5||!a.world!.nearby.includes(b.name)||!b.world!.nearby.includes(a.name))return {accepted:false,reason:'No compatible nearby pair'};
const danger=recentDanger(a)||recentDanger(b);
const willing=(x:Bot,y:Bot)=>affinity(x,y)>=0&&(danger||x.personality.sociability>=35||affinity(x,y)>=5)&&x.personality.activities.some(v=>v==='quest'||v==='explore');
return {accepted:willing(a,b)&&willing(b,a),reason:willing(a,b)&&willing(b,a)?danger?'Seeking safety together after recent danger; compatible faction, levels, and non-hostile companions.':'Both accepted: compatible levels, shared adventure interests, and no hostile bond.':'One character prefers to adventure alone or does not trust the other.'};
}
export function intentCompanions(s:State,a:Bot){
if(!s.community?.partiesEnabled)return [];
const groupSafe=(p:Bot)=>!p.world?.groupId||(p.world.groupNames.length<5&&p.world.groupNames.length>0&&p.world.groupNames.every(n=>s.bots.some(m=>m.name===n&&fresh(m)&&m.world!.groupId===p.world!.groupId)));
if(!groupSafe(a))return [];
return s.bots.filter(b=>!b.world?.groupId&&partyDecision(a,b).accepted).sort((x,y)=>companionScore(a,y)-companionScore(a,x));
}
export async function partyIntentBusy(db:D1Database,a:Bot,b:Bot){
await ensure(db);
const row=await db.prepare("SELECT id FROM community_actions WHERE (a IN (?,?) OR b IN (?,?)) AND (status='pending' OR created_at>?) LIMIT 1").bind(a.id,b.id,a.id,b.id,new Date(Date.now()-120000).toISOString()).first();
return !!row;
}
export async function recordPartyIntent(db:D1Database,s:State,id:string,a:Bot,b:Bot){
const reason='Seeking a nearby compatible adventuring companion; awaiting native confirmation.';
await db.prepare('INSERT INTO community_actions VALUES (?,?,?,?,?,?,?,?)').bind(id,new Date().toISOString(),'party',a.id,b.id,id,'pending',reason).run();
socialEntry(s,{id:id+':proposal',at:new Date().toISOString(),kind:'invitation',names:[a.name,b.name],text:reason,status:'pending'});
}
export function compileCommunityAction(kind:'party'|'leave'|'recruit',a:Bot,b?:Bot){
if(kind==='party'){
if(!b||!a.autonomy?.accountId||!b.autonomy?.accountId)throw new Error('Verified native accounts required');
@@ -85,7 +104,7 @@ async function ensure(db:D1Database){
await db.prepare('CREATE TABLE IF NOT EXISTS community_actions (id TEXT PRIMARY KEY,created_at TEXT,kind TEXT,a TEXT,b TEXT,command_id TEXT,status TEXT,reason TEXT)').run();
await db.prepare('CREATE TABLE IF NOT EXISTS community_clock (id TEXT PRIMARY KEY,at INTEGER)').run();
}
export async function communityTick(store:StateStore,db:D1Database){
export async function communityTick(store:StateStore,db:D1Database,config:RuntimeConfig={}){
await ensure(db);
const pending=await db.prepare("SELECT * FROM community_actions WHERE status='pending' LIMIT 20").all<any>();
for(const action of pending.results){
@@ -129,7 +148,7 @@ export async function communityTick(store:StateStore,db:D1Database){
}
}
async function propose(kind:'party'|'leave'|'recruit',a:Bot,b:Bot|undefined,reason:string){
const command=await queueAleCommand(db,compileCommunityAction(kind,a,b));
const command=await queueAleCommand(db,classicMode(config)?classicCommand(kind,a,kind==='leave'?a.world!.groupId:'',b):compileCommunityAction(kind,a,b));
await db.prepare('INSERT INTO community_actions VALUES (?,?,?,?,?,?,?,?)').bind(command.id,new Date().toISOString(),kind,a.id,b?.id??'',command.id,'pending',reason).run();
await changeCommunity(store,s=>socialEntry(s,{id:command.id+':proposal',at:new Date().toISOString(),kind:'invitation',names:[a.name,...(b?[b.name]:[])],text:reason+' Awaiting game confirmation.',status:'pending'}));
}
+20 -1
View File
@@ -1,4 +1,7 @@
import { z } from 'zod';
import {classicCommand} from '../classic';
import {queueAleCommand} from '../ale-store';
import {runGM} from '../gm';
import type { GameConnector,Bot,Decision,RuntimeConfig } from '../types';
const deathSignal=z.object({
@@ -45,5 +48,21 @@ export class AzerothCoreConnector implements GameConnector {
async decide(bot:Bot,decision:Decision,requestId:string){return z.object({accepted:z.literal(true),summary:z.string().max(1000),zone:z.string().max(120).optional()}).parse(await this.request(`/bots/${encodeURIComponent(bot.id)}/intent`,{decision,requestId}));}
}
export class ClassicConnector implements GameConnector {
id='cmangos-classic';
constructor(private config:RuntimeConfig){}
async health(){const result=await runGM('server info',true,true,this.config);return {ok:true,detail:result.output};}
async observe(bot:Bot){
if(!bot.world||Date.now()/1000-bot.world.at>30)throw new Error('Fresh Classic observation required');
return {zone:bot.zone,level:bot.level,alive:!bot.world.isDead&&!bot.world.isGhost,availableActions:bot.personality.activities,summary:JSON.stringify({zone:bot.zone,level:bot.level,health:bot.world.healthPct,mana:bot.world.manaPct,quests:bot.world.quests,combat:bot.world.inCombat,party:bot.world.groupNames})};
}
async lifecycle(){throw new Error('Classic maintains the fixed 50-character roster automatically. Manual lifecycle changes are not enabled.');}
async decide(bot:Bot,decision:Decision){
if(!this.config.DB)throw new Error('Classic command database is unavailable');
const queued=await queueAleCommand(this.config.DB,classicCommand('intent',bot,decision.action));
return {summary:`${bot.name}: requested ${decision.action}; native receipt and observed outcome are pending (${queued.id}).`,zone:bot.zone};
}
}
// Add a connector factory here; personality and memory code remains game-independent.
export const connectors:Record<string,(config:RuntimeConfig)=>GameConnector>={simulation:()=>new SimulationConnector(),azerothcore:c=>new AzerothCoreConnector(c)};
export const connectors:Record<string,(config:RuntimeConfig)=>GameConnector>={simulation:()=>new SimulationConnector(),azerothcore:c=>c.GAME_BACKEND==='cmangos-classic'?new ClassicConnector(c):new AzerothCoreConnector(c)};
+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,
+4 -2
View File
@@ -1,3 +1,4 @@
import {classicMode,classicCommand,classicPlayerReply} from './classic';
import {bondEvidence,socialEntry,communityTick} from './community';
import { z } from 'zod';
import { queueAleCommand } from './ale-store';
@@ -180,7 +181,7 @@ export async function runConversationTick(store:StateStore,db:D1Database,config:
}
}
await cleanupConversations(db);
await communityTick(store,db);
await communityTick(store,db,config);
const request=await leaseConversation(db);
if(!request)return {ok:true,ran:false,reason:'No player conversation waiting'};
@@ -202,7 +203,8 @@ export async function runConversationTick(store:StateStore,db:D1Database,config:
reply=cleanReply(reply);
if(!reply)throw new Error('Conversation reply was empty');
const command=await queueAleCommand(db,compileReply(bot.name,request.speakerName,reply,request.channel,request.socialTurn!==undefined));
const source=classicMode(config)?(request.socialTurn===undefined?classicPlayerReply(bot,request,reply):classicCommand('say',bot,reply,byName(state,request.speakerName))):compileReply(bot.name,request.speakerName,reply,request.channel,request.socialTurn!==undefined);
const command=await queueAleCommand(db,source);
await awaitDelivery(db,request,bot.id,reply,command.id);
await cleanupConversations(db);
+3 -2
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:'manual'});
const xml=await r.text();if(!r.ok||/<(?:\w+:)?Fault\b/.test(xml))throw new Error(`SOAP command failed (HTTP ${r.status}). Check the worldserver log.`);
const output=xml.match(/<(?:\w+:)?result(?:\s[^>]*)?>([\s\S]*?)<\/(?:\w+:)?result>/)?.[1];
if(output===undefined)throw new Error('Unexpected SOAP response; check the worldserver before retrying');
+25
View File
@@ -0,0 +1,25 @@
import type {Bot,Decision,IntentCheck} from './types';
export function startIntentCheck(bot:Bot,decision:Decision,commandId:string,now:number,partyPeer?:string):IntentCheck|undefined{
const w=bot.world;if(!w||now-w.at>90||now<w.at)return;
return {id:commandId,action:decision.action,startedAt:now,baselineAt:w.at,status:'pending',detail:'Waiting for a fresh game observation; command submission is not completion.',partyPeer,baseline:{level:bot.level,xp:w.xp,map:w.mapId,x:w.x,y:w.y,hp:w.healthPct,mana:w.manaPct,food:w.foodCount,drink:w.drinkCount,durability:w.minDurabilityPct,readinessAt:w.readinessAt,groupNames:[...w.groupNames]}};
}
export function observeIntentChecks(bot:Bot){
const w=bot.world;if(!w)return;
for(const c of bot.autonomy?.intentChecks??[]){
if(c.status!=='pending'||w.at<=c.baselineAt||w.at<c.startedAt)continue;
const finish=(status:IntentCheck['status'],detail:string)=>{c.status=status;c.detail=detail;c.checkedAt=w.at;};
if(w.isDead||w.isGhost){finish('interrupted','Death interrupted the intention; native resurrection takes priority.');continue;}
if(w.humanControlled){finish('interrupted','Human control took priority.');continue;}
const b=c.baseline,elapsed=w.at-c.startedAt;
if(c.action==='quest'&&(bot.level>b.level||(w.xp!==undefined&&b.xp!==undefined&&w.xp>b.xp))){finish('observed','Experience or level increased after the intention; this alone does not prove a quest turn-in.');continue;}
if(c.action==='explore'&&(w.mapId!==b.map||Math.hypot(w.x-b.x,w.y-b.y)>=20)){finish('observed','Travel of at least 20 yards or a map change observed.');continue;}
if(c.action==='rest'&&!w.inCombat&&(w.healthPct??0)>=95&&(w.manaPct===undefined||w.manaPct>=65)){
finish('observed','Recovered health and sufficient mana observed out of combat.');continue;
}
if(c.action==='trade'&&(w.readinessAt??0)>(b.readinessAt??0)&&((w.foodCount??0)>(b.food??Infinity)||(w.drinkCount??0)>(b.drink??Infinity)||(w.minDurabilityPct??0)>(b.durability??Infinity))){finish('observed','Supplies or equipment condition improved; purchase attribution is not established.');continue;}
if(c.action==='socialize'&&c.partyPeer&&w.groupId&&w.groupNames.includes(c.partyPeer)){
finish('observed','Party membership with '+c.partyPeer+' verified in the game.');continue;
}
if(elapsed>=600)finish('no_progress',c.action==='socialize'&&!c.partyPeer?'No measurable party objective was attached; dialogue is counted separately.':'No matching outcome observed within ten minutes. This is not proof of an engine failure.');
}
}
+2 -2
View File
@@ -11,7 +11,7 @@ export type BrainRoute={
function survivalDecision(bot:Bot,observation:Observation){
const w=bot.world;
if(w&&Date.now()/1000-w.at<90&&typeof w.healthPct==='number'&&w.healthPct<85&&bot.personality.activities.includes('rest')&&observation.availableActions.includes('rest'))
if(w&&Date.now()/1000-w.at>=0&&Date.now()/1000-w.at<90&&typeof w.healthPct==='number'&&(w.healthPct<85||(typeof w.manaPct==='number'&&w.manaPct<35))&&bot.personality.activities.includes('rest')&&observation.availableActions.includes('rest'))
return {action:'rest' as const,goal:'Recover before risking another fight.',reason:'Staying alive comes before my task. I need to recover and reassess the danger.'};
return null;
}
@@ -54,7 +54,7 @@ export class LocalLLMProvider implements LLMProvider {
content:JSON.stringify({
identity:{name:bot.name,race:bot.race,role:bot.role,tier:bot.tier},
home:bot.homeId??null,
safety:{healthPercent:bot.world?.healthPct??null,observedAt:bot.world?.at??null,inCombat:bot.world?.inCombat??null,nearbyCompanions:bot.world?.nearby??[],lastSurvivalAction:bot.world?.survivalAction??null},
safety:bot.world&&Date.now()/1000-bot.world.at<90?{manaPercent:bot.world.manaPct??null,healthPercent:bot.world?.healthPct??null,observedAt:bot.world?.at??null,inCombat:bot.world?.inCombat??null,nearbyCompanions:bot.world?.nearby??[],lastSurvivalAction:bot.world?.survivalAction??null}:null,
personality:bot.personality,
goal:bot.goal,
memories:[...bot.memories].sort((a,b)=>b.importance-a.importance).slice(0,memoryLimit),
+9 -2
View File
@@ -3,10 +3,11 @@ export const DAY=86400,BOUNDARY=6*3600;
export const reportWindow=(at:number)=>Math.floor((at-BOUNDARY)/DAY)*DAY+BOUNDARY;
export type Evidence={id:string;at:number;kind:string;name:string;amount:number;detail:string};
export type Progress={partyKey?:string;nearPartyKey?:string;worldAt?:number;deathMemoryId?:number;level:number;status:string;seenOnline:boolean;x?:number;y?:number;map?:number;health?:number;lastProgressAt?:number};
export type Cursor={at:number;startedAt:number;bots:Record<string,Progress>;bonds:string[];guilds:Record<string,{goal:string;progress:number}>;social:string[]};
export type Cursor={intentResults?:string[];at:number;startedAt:number;bots:Record<string,Progress>;bonds:string[];guilds:Record<string,{goal:string;progress:number}>;social:string[]};
export function observeInsights(s:State,previous:Cursor|undefined,now:number){
const events:Evidence[]=[];const cursor:Cursor={at:now,startedAt:previous?.startedAt??now,bots:{},bonds:[],guilds:{},social:[]};
const add=(id:string,kind:string,name:string,amount:number,detail:string,at=now)=>events.push({id,at,kind,name,amount,detail});
cursor.intentResults=[];
const managed=s.bots.filter(b=>b.autonomy?.managed),ids=new Set(managed.map(b=>b.id));
for(const b of managed){
const old=previous?.bots[b.id],w=b.world;
@@ -17,10 +18,16 @@ export function observeInsights(s:State,previous:Cursor|undefined,now:number){
const partyKey=fresh&&w!.groupId&&peers.length?[b.name,...peers].sort().join('|'):undefined;
const nearPartyKey=partyKey&&peers.some(n=>(w!.nearby??[]).includes(n))?partyKey:undefined;
const elapsed=previous?now-previous.at:0;
if(old&&fresh&&w!.at>(old.worldAt??0)&&elapsed>0&&elapsed<=120){
if(old&&fresh&&w!.at>(old.worldAt??0)&&elapsed>0&&elapsed<=180){
if(b.status==='online'&&old.status==='online'){add(`online-time:${b.id}:${now}`,'onlineSeconds',b.name,elapsed,'Fresh consecutive online observations, including recovery time');if(!w!.isDead&&!w!.isGhost&&(moved||w!.inCombat||w!.casting))add(`active-time:${b.id}:${now}`,'activeSeconds',b.name,elapsed,'Sampled movement, combat or casting; not proof of useful progress');}
if(partyKey&&partyKey===old.partyKey)add(`party-time:${b.id}:${now}`,'groupedSeconds',b.name,elapsed,'Sampled seconds in the same living party');
if(nearPartyKey&&nearPartyKey===old.nearPartyKey)add(`party-near:${b.id}:${now}`,'nearPartySeconds',b.name,elapsed,'Sampled seconds with a party member within 35 yards');
}
for(const check of b.autonomy?.intentChecks??[]){
if(check.status==='pending')continue;
cursor.intentResults!.push(check.id);
if(previous&&!(previous.intentResults??[]).includes(check.id)&&(check.checkedAt??0)>=previous.at)add('intent:'+check.id,'intent'+(check.status==='observed'?'Observed':check.status==='interrupted'?'Interrupted':'Unverified'),b.name,1,check.action+': '+check.detail,check.checkedAt);
}
if(previous&&w?.deathMemoryId&&w.deathMemoryId!==(old?.deathMemoryId??0)&&(w.deathMemoryAt??0)>=previous.at)add(`learned-death:${b.id}:${w.deathMemoryId}`,'learnedDeaths',b.name,1,`Encounter remembered: ${w.deathMemoryKiller??'unknown enemy'}`);
cursor.bots[b.id]={partyKey,nearPartyKey,worldAt:fresh?w!.at:old?.worldAt,deathMemoryId:w?.deathMemoryId??old?.deathMemoryId,level:b.level,status:b.status,seenOnline:!!old?.seenOnline||b.status==='online',x:fresh?w!.x:old?.x,y:fresh?w!.y:old?.y,map:fresh?w!.mapId:old?.map,health:fresh?w!.healthPct:old?.health,lastProgressAt:moved||advanced?now:old?.lastProgressAt};
if(previous){
+3 -3
View File
@@ -1,12 +1,12 @@
import type {State} from './types';
import {activityReason,type Evidence} from './realm-insights';
export const counterKeys=['quests','abandoned','lootEvents','lootItems','partyKills','rewarded','lootedCopper'] as const;
export type NativeOutcome={guid:number;name:string}&Record<typeof counterKeys[number],number>;
export const counterKeys=['quests','abandoned','lootEvents','lootItems','partyKills','rewarded','lootedCopper','nativeDeaths'] as const;
export type NativeOutcome={guid:number;name:string}&Record<Exclude<typeof counterKeys[number],'nativeDeaths'>,number>&{nativeDeaths?:number};
export type OutcomeBaseline={at:number;startedAt:number;rows:NativeOutcome[]};
export function outcomeChanges(rows:NativeOutcome[],previous:OutcomeBaseline|undefined,now:number){
const events:Evidence[]=[];
for(const r of rows){const old=previous?.rows.find(b=>b.guid===r.guid);if(!old)continue;
for(const key of counterKeys){const amount=r[key]-old[key];if(amount>0)events.push({id:`outcome:${r.guid}:${key}:${now}:${r[key]}`,at:now,kind:key,name:r.name,amount,detail:({lootedCopper:'Currency looted',quests:'Quest objectives completed',abandoned:'Quests abandoned',lootEvents:'Loot collections',lootItems:'Items collected',partyKills:'Kills with a nearby bot attacking the same target',rewarded:'New rewarded quest records'})[key]});}
for(const key of counterKeys){const value=r[key],prior=old[key];if(typeof value!=='number'||typeof prior!=='number')continue;const amount=value-prior;if(amount>0)events.push({id:`outcome:${r.guid}:${key}:${now}:${r[key]}`,at:now,kind:key,name:r.name,amount,detail:({nativeDeaths:'Native death records, including normal resurrection',lootedCopper:'Currency looted',quests:'Quest objectives completed',abandoned:'Quests abandoned',lootEvents:'Loot collections',lootItems:'Items collected',partyKills:'Kills with a nearby bot attacking the same target',rewarded:'New rewarded quest records'})[key]});}
}
return {baseline:{at:now,startedAt:previous?.startedAt??now,rows},events};
}
+16 -3
View File
@@ -70,6 +70,16 @@ function observedHardcoreDeath(s:State,b:Bot,observation:Observation){
return true;
}
// Apply one transport batch atomically; one large state write instead of one per bot.
export async function applyAuthoritativeAleSnapshots(store:StateStore,payloads:Record<string,unknown>[]){
const initial=await store.load(),revision=initial.revision;
let staged=structuredClone(initial),dirty=false;
const batchStore:StateStore={load:async()=>staged,save:async(next)=>{staged=next;dirty=true;}};
for(const payload of payloads)await applyAuthoritativeAleEvent(batchStore,'player.snapshot',payload);
if(dirty)await store.save(staged,revision);
return {ok:true,count:payloads.length};
}
export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,payload:Record<string,unknown>){
let s=await store.load();
const revision=s.revision;
@@ -200,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){
@@ -226,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.');}
@@ -283,7 +296,7 @@ export async function execute(store:StateStore,input:unknown,config:RuntimeConfi
s.recipes=old?s.recipes.map(r=>r.id===old.id?recipe:r):[...s.recipes,recipe];event(s,'recipe',`${recipe.name} saved as version ${recipe.version}. Existing bots retain their snapshot.`);
}else if(a.type==='settings'){
if(a.connector!==s.settings.connector&&s.bots.some(b=>b.status==='online'||b.status==='paused'))throw new Error('Despawn all online and paused bots before switching connectors.');
if(a.connector==='azerothcore'&&(!config.BRIDGE_URL||!config.BRIDGE_TOKEN))throw new Error('AzerothCore bridge is not configured on the server.');
if(a.connector==='azerothcore'&&config.GAME_BACKEND!=='cmangos-classic'&&(!config.BRIDGE_URL||!config.BRIDGE_TOKEN))throw new Error('AzerothCore bridge is not configured on the server.');
if(a.provider!=='deterministic'&&!config.LLM_URL)throw new Error('Local LLM endpoint is not configured on the server.');
s.settings={connector:a.connector,provider:a.provider,model:a.model,brains:a.brains,hardcore:a.hardcore,population:a.population};
event(s,'system',`Realm connector and model settings updated. Hardcore one-life mode is ${a.hardcore.enabled?'enabled':'disabled'}.`);
+4 -3
View File
@@ -9,8 +9,9 @@ export type Relationship = {targetId:string; affinity:number; note:string};
export type DeathRecord = {id:string; at:string; zone:string; level:number; cause:string; killer?:string; source:'operator'|'simulation'|'azerothcore'; verified:boolean};
export type Home = {id:string; name:string; zone:string; type:HousingType; capacity:number; ownerIds:string[]; residentIds:string[]; storageSlots:number; safety:number; description:string};
export type Routine = {wakeHour:number; sleepHour:number; preferredZones:string[]};
export type AutonomyState = {managed:boolean; characterGuid?:number; accountId?:number; observedAt?:string; lastIntentAt?:string; nextIntentAt?:string; lastAleCommandId?:string};
export type WorldPresence = {trainingPending?:number;trainingLevel?:number;trainingTrainer?:string;trainingStatus?:string;trainingAt?:number;trainingLearned?:number;lastTrainingAt?:number;survivalSince?:number;isDead?:boolean;isGhost?:boolean;manaPct?:number;leaderName?:string;minDurabilityPct?:number;brokenItems?:number;freeTalents?:number;foodCount?:number;drinkCount?:number;readinessAt?:number;deathMemoryId?:number;deathMemoryAt?:number;deathMemoryKiller?:string;deathMemoryMap?:number;deathMemoryX?:number;deathMemoryY?:number;deathMemoryLevel?:number;quests?:number[];dangerAt?:number;survivalAction?:string;survivalAt?:number;combatRole?:string;casting?:boolean;taxi?:boolean;humanControlled?:boolean;movementType?:number;healthPct?:number;at:number;team:number;guildId:number;guildName:string;guildRank:number;groupId:string;groupNames:string[];nearby:string[];inCombat:boolean;x:number;y:number;mapId:number;stationarySince?:number};
export type IntentCheck = {id:string;action:Activity;startedAt:number;baselineAt:number;checkedAt?:number;status:"pending"|"observed"|"no_progress"|"interrupted";detail:string;partyPeer?:string;baseline:{level:number;xp?:number;map:number;x:number;y:number;hp?:number;mana?:number;food?:number;drink?:number;durability?:number;readinessAt?:number;groupNames:string[]}};
export type AutonomyState = {intentChecks?:IntentCheck[];managed:boolean; characterGuid?:number; accountId?:number; observedAt?:string; lastIntentAt?:string; nextIntentAt?:string; lastAleCommandId?:string};
export type WorldPresence = {xp?:number;questBrief?:string;threatBrief?:string;trainingPending?:number;trainingLevel?:number;trainingTrainer?:string;trainingStatus?:string;trainingAt?:number;trainingLearned?:number;lastTrainingAt?:number;survivalSince?:number;isDead?:boolean;isGhost?:boolean;manaPct?:number;leaderName?:string;minDurabilityPct?:number;brokenItems?:number;freeTalents?:number;foodCount?:number;drinkCount?:number;readinessAt?:number;deathMemoryId?:number;deathMemoryAt?:number;deathMemoryKiller?:string;deathMemoryMap?:number;deathMemoryX?:number;deathMemoryY?:number;deathMemoryLevel?:number;quests?:number[];dangerAt?:number;survivalAction?:string;survivalAt?:number;combatRole?:string;casting?:boolean;taxi?:boolean;humanControlled?:boolean;movementType?:number;healthPct?:number;at:number;team:number;guildId:number;guildName:string;guildRank:number;groupId:string;groupNames:string[];nearby:string[];inCombat:boolean;x:number;y:number;mapId:number;stationarySince?:number};
export type SocialEntry = {id:string;at:string;kind:string;names:string[];text:string;affinityDelta?:number;status?:string};
export type GuildGoal = {id:number;name:string;goal:'quest'|'support'|'explore';progress:number;target:number};
export type Community = {feed:SocialEntry[];evidence:string[];guilds:GuildGoal[];partiesEnabled:boolean;guildsEnabled:boolean};
@@ -28,4 +29,4 @@ export type Observation = {zone:string; level:number; availableActions:Activity[
export interface GameConnector {id:string; health():Promise<{ok:boolean; detail:string}>; observe(bot:Bot):Promise<Observation>; lifecycle(bot:Bot,action:'spawn'|'pause'|'resume'|'despawn'|'retire',requestId:string):Promise<void>; decide(bot:Bot,decision:Decision,requestId:string):Promise<{summary:string;zone?:string}>;}
export interface LLMProvider {id:string; decide(bot:Bot,observation:Observation):Promise<Decision>}
export interface StateStore {load():Promise<State>; save(state:State,expectedRevision:number):Promise<void>}
export type RuntimeConfig = {SOAP_URL?:string; SOAP_USER?:string; SOAP_PASSWORD?:string; BRIDGE_URL?:string; BRIDGE_TOKEN?:string; ALE_BRIDGE_TOKEN?:string; ALE_OPERATOR_TOKEN?:string; ALE_RAW_EXEC_ENABLED?:string; AUTONOMY_TOKEN?:string; LLM_URL?:string; LLM_API_KEY?:string};
export type RuntimeConfig = {DB?:D1Database;GAME_BACKEND?:string;SOAP_URL?:string; SOAP_USER?:string; SOAP_PASSWORD?:string; BRIDGE_URL?:string; BRIDGE_TOKEN?:string; ALE_BRIDGE_TOKEN?:string; ALE_OPERATOR_TOKEN?:string; ALE_RAW_EXEC_ENABLED?:string; AUTONOMY_TOKEN?:string; LLM_URL?:string; LLM_API_KEY?:string};
+100
View File
@@ -0,0 +1,100 @@
# Classic migration staging
Target: CMaNGOS Classic, client 1.12.1 build 5875, with CMaNGOS Playerbots.
This directory contains the staged adapter and migration helpers. A successful
build is not a completed migration or permission to bypass gameplay validation.
`stage-data.py --root /path/to/isolated/migration` observes an existing
`azerothai-classic-build` container, verifies the transferred MPQs against
`client-manifest.json`, and runs the upstream extractor with two worker threads.
The manifest maps relative MPQ paths to `size` and `sha256`. Checksums verify
transfer integrity against the supplied client manifest, not publisher authenticity.
The Docker builder image must be named `azerothai-classic-builder:local`; the
installed extractor must be under `run/bin/tools`. The staging directory and
container files belong to UID/GID 1000. Containers have a two-core/four-GiB limit.
Progress and failures are written to `prerequisites-status.json`. A successful
extraction is only a prerequisite: database import, the Classic control adapter,
the 50-character roster, account protection, and gameplay validation remain.
The script never starts the new realm, changes the old realm, or deletes bots.
`install-native.py SOURCE` installs the native adapter and version-checked hooks.
The adapter provides identity-bound snapshots, typed intentions, guarded native
party/guild invitations, bot-to-bot speech, durable command receipts, and Zaku's
damage floor. `AzerothAI.RestrictBotLogin=1` restricts native bot logins to an
explicit roster of at most 50 identities. `AzerothAI.Enable=1` enables observations
and managed login maintenance. Both settings default off in upstream builds.
`plan-roster.py ACTIVE_JSON OUTPUT_JSON` maps exactly 50 saved identities to
valid Vanilla combinations without changing faction. Death Knights become
Warriors, Alliance Shamans become Paladins, and Horde Paladins become Shamans.
Names remain stable; all characters are created at level 1 using native creation.
`bootstrap.py --root ROOT` waits for successful extraction and native compilation,
configures the isolated Classic world/database, starts a localhost-only game
port, creates the planned characters through the native console, and validates
all 50 identities and levels. It leaves those characters **offline**, writes
`classic-roster-bound.json`, and records progress in `bootstrap-status.json`.
It does not start authentication, change client configuration, or stop Wrath.
`prepare-state.py --root ROOT --live LIVE_REPO` prepares a separate dashboard
database from a consistent SQLite backup. It preserves active personalities and
labels older memories as historical, rebinds native IDs, and resets current-world
observations, command queues, and the population plan. It refuses to overwrite
an existing staging database. Run before starting the staging web runtime.
The staging app requires `GAME_BACKEND=cmangos-classic`. `relay.py --root ROOT
--url http://127.0.0.1:8791 --env-file ROOT/app-staging/.dev.vars` transfers only
validated typed commands into the Classic character database. It never executes
Lua or arbitrary game commands. Native receipts distinguish accepted intentions
from observed gameplay outcomes; interrupted commands are not replayed.
`install-staging-services.py --root ROOT` installs isolated user services for
port 8791, the native relay, and autonomy. Stop manually launched staging
processes first. Services restart on failure and leave the live Wrath services
unchanged. The worker credential file is private to the owner.
The rebuildable item-info cache uses InnoDB with a dynamic row format so the
upstream builder's transaction batches writes with full database durability.
The bootstrap records the existing native bot account range for login
registration and enables activity even when no human player is present.
Automatic population creation remains disabled; the native identity allowlist
still limits managed login to the planned 50.
Before cutover, verify fresh 50-character observations, quest and spell outcomes,
normal resurrection, actual party receipts, native GM commands, authentication,
and rollback. Human player replies require an actual in-game session for an
end-to-end acceptance check. Lua/ALE reflection remains a Wrath-only capability.
Migration safeguards:
- Preserve the verified Wrath backup and the active-50 identity export.
- Maintain 25 Alliance and 25 Horde. Use valid Vanilla race/class combinations.
- Start new Vanilla characters at level 1; do not directly import Wrath inventory,
talent, spell, or quest tables. Blood Elves, Draenei, Death Knights, Alliance
Shamans, and Horde Paladins need explicit compatible replacements.
- Retain personalities and historical memories; reset live observations, GUIDs,
action queues, and progress baselines when binding the new characters.
- Protect human/GM accounts. Inactive managed profiles are cleanup candidates,
not proof that corresponding game characters can safely be deleted.
- Test native bot login, progress, resurrection, safety rules, read-only visitor
access, and current observations before switching the live dashboard or client.
Native outcome hooks now report quest completion/rewards, loot item counts,
looted copper, deaths, resurrection and learned spells. These are separate from
accepted intentions. The configured native auto-learn policy is invoked on
managed level changes; this is not a claim of trainer purchases. Player say and
whisper events bind the sender's native character/account identity and replies
revalidate that identity. Basic GM commands use Classic's own SOAP namespace;
Wrath-only Lua operator calls are rejected explicitly.
`prepare-auth.py --root ROOT` prepares a private Classic service GM account,
copies only ENDAMI's existing SRP credentials into the isolated authentication
database, and disables upstream sample logins. `check-auth.py --root ROOT`
verifies a complete SRP handshake and realm listing without entering the game.
`health.py --root ROOT --url URL` reports host health without Wrath SQL.
`cutover.py --root ROOT` requires a passing build and all 50 fresh staging bots,
backs up stopped Wrath accounts/characters, switches the existing endpoints, and
automatically restores Wrath endpoints if login or population verification fails.
Miniflare metadata databases are excluded from game-state backup selection.
+21
View File
@@ -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
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Resume isolated Classic startup and native level-one roster creation.
Never switches ports, touches Wrath databases, or starts bot login. Native
characters remain offline until the connector and gameplay checks are ready.
"""
import argparse
import json
import os
import pathlib
import re
import subprocess
import time
def main(root):
credentials=json.loads((root/'database-credentials.json').read_text())
env=dict(os.environ,MYSQL_PWD=credentials['root'])
def mysql(sql):
result=subprocess.run(['docker','exec','-i','-e','MYSQL_PWD','azerothai-classic-db','mysql','-uroot','-N','-B','--raw','classiccharacters'],input=sql,text=True,capture_output=True,env=env)
if result.returncode: raise RuntimeError('Isolated Classic database operation failed: '+result.stderr.replace(credentials['root'],'[redacted]').replace(credentials['password'],'[redacted]'))
return result.stdout.strip()
def status(stage,**extra):
value={'at':time.time(),'stage':stage,'cutover':False,**extra}
temp=root/'bootstrap-status.tmp';temp.write_text(json.dumps(value,indent=2));temp.replace(root/'bootstrap-status.json')
print(stage,flush=True)
def inspect(name):
result=subprocess.run(['docker','inspect','--format','{{json .State}}',name],capture_output=True,text=True)
return json.loads(result.stdout) if result.returncode==0 else None
def configure(name,values):
template=root/'run/etc'/f'{name}.conf.dist'
text=template.read_text()
for key,value in values.items():
line=key+' = '+str(value)
pattern=r'^'+re.escape(key)+r'\s*=.*$'
text=re.sub(pattern,lambda _:line,text,flags=re.M) if re.search(pattern,text,re.M) else text+'\n'+line+'\n'
target=template.with_suffix('');target.write_text(text);target.chmod(0o600)
try:
status('waiting for verified extraction and native build')
deadline=time.time()+6*3600
while time.time()<deadline:
extraction=inspect('azerothai-classic-extract');build=inspect('azerothai-classic-native-build')
if extraction and build and not extraction['Running'] and not build['Running']:
if extraction['ExitCode'] or build['ExitCode']:raise RuntimeError('Extraction or native build failed; see its log')
break
time.sleep(15)
else:raise RuntimeError('Prerequisite wait exceeded six hours')
# Require the verifier, not merely a zero process exit.
for _ in range(12):
stage=json.loads((root/'prerequisites-status.json').read_text())['stage']
if stage.startswith('prerequisites extracted;'):break
time.sleep(5)
else:raise RuntimeError('Extraction verifier has not accepted generated data')
for directory in ['dbc','maps','vmaps','mmaps']:
if not any((root/'extracted'/directory).glob('*')):raise RuntimeError('Missing '+directory)
roster=json.loads((root/'classic-roster-plan.json').read_text())
if len(roster)!=50 or sum(b['team']==0 for b in roster)!=25 or sum(b['team']==1 for b in roster)!=25:raise RuntimeError('Invalid roster plan')
if any(not re.fullmatch('[A-Za-z]{2,12}',b['name']) or b['level']!=1 for b in roster):raise RuntimeError('Invalid native creation parameters')
existing=mysql('SELECT name FROM characters').splitlines()
if any(name not in {b['name'] for b in roster} for name in existing):raise RuntimeError('Unexpected characters in isolated database; refusing to modify')
mysql((root/'adapter/schema.sql').read_text())
engine=mysql("SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA='classiccharacters' AND TABLE_NAME='ai_playerbot_item_info_cache'")
if engine!='InnoDB':mysql('ALTER TABLE ai_playerbot_item_info_cache ENGINE=InnoDB, ROW_FORMAT=DYNAMIC;')
(root/'run/logs').mkdir(exist_ok=True)
values={'DataDir':'"/work/extracted"','LogsDir':'"/work/run/logs"','WorldServerPort':8086,'Console.Enable':1,'Ra.Enable':0,'SOAP.Enabled':0,'AzerothAI.Enable':0,'AzerothAI.RestrictBotLogin':1}
for key,db in [('Login','classicrealmd'),('World','classicmangos'),('Character','classiccharacters'),('Logs','classiclogs')]:values[key+'DatabaseInfo']='"azerothai-classic-db;3306;'+credentials['user']+';'+credentials['password']+';'+db+'"'
configure('mangosd',values)
configure('aiplayerbot',{'AiPlayerbot.Enabled':1,'AiPlayerbot.DisableBotOptimizations':1,'AiPlayerbot.DisableActivityPriorities':1,'AiPlayerbot.botActiveAlone':100,'AiPlayerbot.RandomBotAutologin':0,'AiPlayerbot.RandomBotLoginAtStartup':0,'AiPlayerbot.BotAutologin':0,'AiPlayerbot.RandomBotAutoCreate':0,'AiPlayerbot.MinRandomBots':50,'AiPlayerbot.MaxRandomBots':50,'AiPlayerbot.RandomBotAccountCount':0,'AiPlayerbot.RandomBotAccountPrefix':'AZCLASSIC','AiPlayerbot.RandomBotGuildCount':0,'AiPlayerbot.RandomBotArenaTeamCount':0,'AiPlayerbot.DisableRandomLevels':1,'AiPlayerbot.RandomGearUpgradeEnabled':0,'AiPlayerbot.AutoLearnTrainerSpells':1,'AiPlayerbot.AutoTrainSpells':'yes','AiPlayerbot.RandomBotJoinBG':0,'AiPlayerbot.RandomBotJoinLfg':0,'AiPlayerbot.RandomBotMaps':'0,1'})
fifo=root/'run/console'
if not fifo.exists():os.mkfifo(fifo,0o600)
state=inspect('azerothai-classic-world')
if state is None:
subprocess.run(['docker','run','-d','--name','azerothai-classic-world','--network','azerothai-classic','--cpus','2','--memory','3g','--user','1000:1000','-v',str(root)+':/work','-w','/work/run/bin','-p','127.0.0.1:8086:8086','azerothai-classic-builder:local','bash','-c','exec 3<>/work/run/console; exec ./mangosd -c /work/run/etc/mangosd.conf <&3'],check=True,capture_output=True)
elif not state['Running']:subprocess.run(['docker','start','azerothai-classic-world'],check=True,capture_output=True)
status('starting isolated Classic world; bot login disabled')
for attempt in range(720):
state=inspect('azerothai-classic-world')
if not state or not state['Running']:raise RuntimeError('Classic world exited during startup; inspect container log')
log=subprocess.run(['docker','logs','--since',state['StartedAt'],'azerothai-classic-world'],capture_output=True,text=True).stdout
if 'SERVER STARTUP TIME' in log or 'World initialized' in log:break
if attempt%12==0:status('Classic first startup is building caches; bot login disabled',waitedSeconds=attempt*5)
time.sleep(5)
else:raise RuntimeError('Classic startup not confirmed within sixty minutes; inspect cache progress')
# Restore full durability before any persistent characters are created.
mysql('SET GLOBAL innodb_flush_log_at_trx_commit=1; SET GLOBAL sync_binlog=1;')
status('creating 50 level-one native characters; bot login disabled')
with fifo.open('w') as console:
for bot in roster:
name=bot['name']
if not mysql("SELECT guid FROM characters WHERE name='"+name+"'"):
console.write(f"rndbot create name={name} race={bot['raceId']} class={bot['classId']} level=1 login=false gear=none\n");console.flush()
for _ in range(20):
if mysql("SELECT guid FROM characters WHERE name='"+name+"'"):break
time.sleep(1)
else:raise RuntimeError('Native creation not confirmed for '+name)
bound=[]
for bot in roster:
row=mysql("SELECT guid,account,race,class,level,online FROM characters WHERE name='"+bot['name']+"'").split('\t')
if len(row)!=6:raise RuntimeError('Missing character identity')
guid,account,race,cls,level,online=map(int,row)
if (race,cls,level,online)!=(bot['raceId'],bot['classId'],1,0):raise RuntimeError('Native identity or level validation failed: '+bot['name'])
mysql(f"INSERT INTO azerothai_roster(guid,account_id,name,enabled,plot_armor) VALUES ({guid},{account},'{bot['name']}',0,{int(bot['name']=='Zaku')}) ON DUPLICATE KEY UPDATE account_id=VALUES(account_id),name=VALUES(name)")
bound.append(dict(bot,characterGuid=guid,accountId=account))
if int(mysql('SELECT COUNT(*) FROM characters'))!=50:raise RuntimeError('Population differs from 50')
(root/'classic-roster-bound.json').write_text(json.dumps(bound,indent=2)+'\n')
# Native login authorization reloads this account range on restart.
account_limit=int(mysql("SELECT COALESCE(MAX(CAST(SUBSTRING(username,10) AS UNSIGNED))+1,0) FROM classicrealmd.account WHERE username REGEXP '^AZCLASSIC[0-9]+$'"))
config_path=root/'run/etc/aiplayerbot.conf'
config_path.write_text(re.sub(r'^AiPlayerbot.RandomBotAccountCount\s*=.*$',f'AiPlayerbot.RandomBotAccountCount = {account_limit}',config_path.read_text(),flags=re.M))
status('Classic world ready; 50 level-one characters verified offline; connector and gameplay validation remain',characters=50,alliance=25,horde=25)
except Exception as error:
status('bootstrap blocked',error=str(error).replace(credentials['root'],'[redacted]').replace(credentials['password'],'[redacted]'))
raise
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('--root',type=pathlib.Path,required=True)
main(parser.parse_args().root.resolve())
+5
View File
@@ -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;
+43
View File
@@ -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()))
+100
View File
@@ -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())
+19
View File
@@ -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
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Install the version-checked Classic adapter into an isolated CMaNGOS checkout."""
import argparse
import pathlib
import shutil
def patch(path, anchor, replacement):
text = path.read_text()
if replacement in text:
return
if text.count(anchor) != 1:
raise RuntimeError(f"Upstream source changed: {path.name}")
path.write_text(text.replace(anchor, replacement))
def install(source):
here = pathlib.Path(__file__).resolve().parent
for path in (here / "native").glob("AzerothAI.*"):
shutil.copy2(path, source / "src/game/World" / path.name)
for relative, anchor, invocation in [
("src/game/World/World.cpp", " sMapMgr.Update(diff);", "AzerothAIUpdate(diff);"),
("src/game/Entities/Unit.cpp", " uint32 health = victim->GetHealth();", "damage = AzerothAIBoundDamage(victim, damage);")
]:
path = source / relative
include = '#ifdef ENABLE_PLAYERBOTS\n#include "World/AzerothAI.h"\n#endif\n'
if include not in path.read_text():
path.write_text(include + path.read_text())
patch(path, anchor, anchor + "\n#ifdef ENABLE_PLAYERBOTS\n " + invocation + "\n#endif")
# Upstream treats unknown gear modes as generated equipment. Explicitly
# preserve ordinary character-creation equipment for our level-one roster.
path = source / "src/modules/PlayerBots/playerbot/PlayerbotMgr.cpp"
anchor = ' sRandomPlayerbotMgr.SetValue(botGuid, "create gear", 1, gear);'
patch(path, anchor, ' if (gear != "none")\n ' + anchor)
path = source / "src/game/Entities/CharacterHandler.cpp"
include = '#ifdef ENABLE_PLAYERBOTS\n#include "World/AzerothAI.h"\n#endif\n'
if include not in path.read_text():
path.write_text(include + path.read_text())
anchor = 'void PlayerbotHolder::AddPlayerBot(uint32 playerGuid, uint32 masterAccount)\n{'
patch(path, anchor, anchor + '\n if (!AzerothAIAllowBotLogin(playerGuid)) return;')
# Hooks record successful native outcomes, not inferred command success.
player = source / "src/game/Entities/Player.cpp"
loot = source / "src/game/Loot/LootMgr.cpp"
for path in [player, loot]:
if include not in path.read_text():
path.write_text(include + path.read_text())
hooks = [
(player, " // resend quests status directly\n UpdateForQuestWorldObjects();", 'AzerothAIEvent(this,"player.quest.reward",quest_id);'),
(player, "void Player::CompleteQuest(uint32 quest_id)\n{\n if (quest_id)\n {\n SetQuestStatus(quest_id, QUEST_STATUS_COMPLETE);", 'AzerothAIEvent(this,"player.quest.complete",quest_id);'),
(player, " SetDeathState(CORPSE);", 'AzerothAIEvent(this,"player.death");'),
(loot, " plr->ModifyMoney(money_per_player);", 'AzerothAIEvent(plr,"player.loot.money",0,money_per_player);'),
(loot, " player->ModifyMoney(m_gold);", 'AzerothAIEvent(player,"player.loot.money",0,m_gold);'),
]
for path, anchor, call in hooks:
if call:
patch(path, anchor, anchor + "\n#ifdef ENABLE_PLAYERBOTS\n " + call + "\n#endif")
for anchor, call in [
(" if (!applySickness)\n return;", 'AzerothAIEvent(this,"player.resurrect");'),
(" WorldPacket data(SMSG_LEARNED_SPELL, 4);", 'AzerothAIEvent(this,"player.spell.learn",spell_id);'),
(" WorldPacket data(SMSG_ITEM_PUSH_RESULT, (8 + 4 + 4 + 4 + 1 + 4 + 4 + 4 + 4 + 4));", 'if (!received && !created) AzerothAIEvent(this,"player.loot.item",item->GetEntry(),count);'),
]:
patch(player, anchor, "#ifdef ENABLE_PLAYERBOTS\n " + call + "\n#endif\n" + anchor)
chat=source / "src/game/Chat/ChatHandler.cpp"
if include not in chat.read_text():chat.write_text(include+chat.read_text())
for anchor,call in [
(' GetPlayer()->Say(msg, lang);', 'if(lang!=LANG_ADDON && !m_anticheat->IsSilenced()) AzerothAIChat(GetPlayer(),msg);'),
(' if (lang != LANG_ADDON && !m_anticheat->IsSilenced())\n m_anticheat->Whisper(msg, player->GetObjectGuid());', 'if(lang!=LANG_ADDON && !m_anticheat->IsSilenced()) AzerothAIChat(GetPlayer(),msg,player);')
]:patch(chat,anchor,anchor+'\n#ifdef ENABLE_PLAYERBOTS\n '+call+'\n#endif')
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("source", type=pathlib.Path)
install(parser.parse_args().source.resolve())
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Install isolated, restartable Classic dashboard/relay/autonomy user services.
Prepare staging state first and stop any manually started staging processes.
This does not enable game login, start authentication, or change live services.
"""
import argparse
import os
import pathlib
import subprocess
def main(root):
app = root / 'app-staging'
node = pathlib.Path.home() / '.local/bin/node'
for path in [node, app / '.dev.vars', root / 'adapter/relay.py']:
if not path.is_file():
raise RuntimeError('Missing prerequisite: ' + str(path))
if any(c in str(root) for c in [' ', '\n', '"', "'", '%']):
raise RuntimeError('Use a simple absolute staging path')
values = {}
for line in (app / '.dev.vars').read_text().splitlines():
if '=' in line and not line.lstrip().startswith('#'):
key, value = line.split('=', 1)
values[key.strip()] = value.strip().strip('"\'')
token = values['AUTONOMY_TOKEN']
if not token or any(c in token for c in ['\n', '\r', '"', '\\']):
raise RuntimeError('Invalid autonomy token format')
environment = root / 'staging-worker.env'
fd = os.open(environment, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, 'w') as out:
out.write('AUTONOMY_TOKEN="' + token + '"\nAZEROTHAI_URL=http://127.0.0.1:8791\n')
environment.chmod(0o600)
units = pathlib.Path.home() / '.config/systemd/user'
units.mkdir(parents=True, exist_ok=True)
commands = {
'runtime': (f'{node} {app}/scripts/start-local-runtime.mjs', 'Environment=RUNTIME_PORT=8791 RUNTIME_HOST=127.0.0.1\n'),
'relay': (f'/usr/bin/sg docker -c "/usr/bin/python3 {root}/adapter/relay.py --root {root} --url http://127.0.0.1:8791 --env-file {app}/.dev.vars"', ''),
'autonomy': (f'{node} {app}/scripts/autonomy-worker.mjs', f'EnvironmentFile={environment}\n'),
}
names = []
for kind, (command, extra) in commands.items():
name = f'azerothai-classic-{kind}.service'
names.append(name)
after = 'network.target' if kind == 'runtime' else 'azerothai-classic-runtime.service'
(units / name).write_text(f'''[Unit]
Description=AzerothAI Classic staging {kind}
After={after}
[Service]
Type=simple
WorkingDirectory={app}
Environment=PATH={node.parent}:/usr/local/bin:/usr/bin:/bin
{extra}ExecStart={command}
Restart=on-failure
RestartSec=5
TimeoutStopSec=20
[Install]
WantedBy=default.target
''')
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
subprocess.run(['systemctl', '--user', 'enable', '--now', *names], check=True)
subprocess.run(['systemctl', '--user', 'is-active', *names], check=True)
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--root', type=pathlib.Path, required=True)
main(parser.parse_args().root.resolve())
+193
View File
@@ -0,0 +1,193 @@
#include "AzerothAI.h"
#include "Entities/Player.h"
#include "World/World.h"
#include "Server/WorldSession.h"
#include "Server/DBCStores.h"
#include "Globals/ObjectMgr.h"
#include "Globals/SharedDefines.h"
#include "Groups/Group.h"
#include "Config/Config.h"
#include "Database/DatabaseEnv.h"
#include "playerbot/PlayerbotAI.h"
#include "playerbot/PlayerbotAIConfig.h"
#include "playerbot/RandomPlayerbotMgr.h"
#include <atomic>
#include <algorithm>
#include <map>
#include <sstream>
#include <iomanip>
#include <ctime>
namespace {
struct Resident { uint32_t guid, account; std::string name; bool armor; };
std::map<uint32_t, Resident> roster;
std::atomic<uint32_t> armorGuid{0}, armorAccount{0};
std::string sql(std::string text) { CharacterDatabase.escape_string(text); return text; }
std::string quoted(const std::string& text) {
std::ostringstream out; out << '"';
for (unsigned char c : text) {
if (c == '"' || c == '\\') out << '\\' << c;
else if (c < 32) out << "\\u" << std::hex << std::setw(4) << std::setfill('0') << unsigned(c);
else out << c;
}
out << '"'; return out.str();
}
bool identified(Player* p) {
if (!p || !p->IsInWorld()) return false;
auto r = roster.find(p->GetGUIDLow());
return r != roster.end() && p->GetName() == r->second.name && p->GetSession()->GetAccountId() == r->second.account
&& p->GetPlayerbotAI();
}
bool managed(Player* p) { return identified(p) && !p->GetPlayerbotAI()->IsRealPlayer() && !p->GetPlayerbotAI()->HasRealPlayerMaster(); }
bool safeGroup(Player* p) {
Group* g = p->GetGroup(); if (!g) return true;
unsigned count = 0;
for (GroupReference* r = g->GetFirstMember(); r; r = r->next()) {
if (!managed(r->getSource())) return false;
++count;
}
return count == g->GetMembersCount();
}
bool free(Player* p) { return managed(p) && p->IsAlive() && !p->IsInCombat() && !p->IsTaxiFlying() && !p->IsNonMeleeSpellCasted(false); }
bool nearby(Player* a, Player* b, float range) { return free(a) && free(b) && a->GetTeam() == b->GetTeam() && a->GetMapId() == b->GetMapId() && a->GetDistance(b) <= range; }
void receipt(const std::string& id, bool ok, const std::string& result) {
CharacterDatabase.PExecute("UPDATE azerothai_commands SET status='%s',result='%s' WHERE id='%s' AND status='processing'", ok ? "completed" : "failed", sql(result).c_str(), sql(id).c_str());
}
void commands() {
auto q = CharacterDatabase.Query("SELECT id,guid,account_id,name,kind,argument,peer_guid,created_at,peer_account_id,peer_name,expires_at FROM azerothai_commands WHERE status='pending' ORDER BY created_at LIMIT 8");
if (!q) return;
do {
Field* f=q->Fetch(); std::string id=f[0].GetString(), kind=f[4].GetString(), arg=f[5].GetString();
// One world-thread consumer claims durably before any game effect.
if (!CharacterDatabase.DirectPExecute("UPDATE azerothai_commands SET status='processing' WHERE id='%s' AND status='pending'",sql(id).c_str())) continue;
Player* p=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,f[1].GetUInt32()));
Player* peer=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,f[6].GetUInt32()));
if (!managed(p) || p->GetSession()->GetAccountId()!=f[2].GetUInt32() || std::string(p->GetName())!=f[3].GetString() || std::time(nullptr)-f[7].GetUInt32()>90 || std::time(nullptr)>f[10].GetUInt32()) { receipt(id,false,"Stale command or managed identity unavailable"); continue; }
bool humanReply=kind=="reply-say" || kind=="reply-whisper";
bool peerAllowed=humanReply ? peer && peer->IsInWorld() && (!peer->GetPlayerbotAI() || peer->GetPlayerbotAI()->IsRealPlayer()) : managed(peer);
if (f[6].GetUInt32() && (!peerAllowed || peer->GetSession()->GetAccountId()!=f[8].GetUInt32() || std::string(peer->GetName())!=f[9].GetString())) {receipt(id,false,"Peer identity changed or protected");continue;}
if (humanReply) {
if (arg.empty() || arg.size()>1000 || !peer || p->GetTeam()!=peer->GetTeam() || (kind=="reply-say" && (p->GetMapId()!=peer->GetMapId() || p->GetDistance(peer)>25))) {receipt(id,false,"Player conversation no longer available");continue;}
if(kind=="reply-whisper")p->Whisper(arg,LANG_UNIVERSAL,peer->GetObjectGuid());
else p->Say(arg,p->GetTeam()==ALLIANCE?LANG_COMMON:LANG_ORCISH);
receipt(id,true,"In-game player reply sent");
} else if (kind=="party") {
if (!nearby(p,peer,35) || !safeGroup(p) || !safeGroup(peer) || peer->GetGroup() || peer->GetGroupInvite() || p->GetGroupInvite() || (p->GetGroup() && (p->GetGroup()->GetMembersCount()>=5 || p->GetGroup()->GetLeaderGuid()!=p->GetObjectGuid()))) { receipt(id,false,"Party participants unavailable or protected"); continue; }
WorldPacket invite(CMSG_GROUP_INVITE,32); invite << std::string(peer->GetName()); p->GetSession()->HandleGroupInviteOpcode(invite);
if (peer->GetGroupInvite()) { WorldPacket accept(CMSG_GROUP_ACCEPT,0); peer->GetSession()->HandleGroupAcceptOpcode(accept); }
receipt(id,p->GetGroup() && p->GetGroup()==peer->GetGroup(),"Native party invitation processed");
} else if (kind=="recruit") {
if (!nearby(p,peer,35) || !p->GetGuildId() || p->GetRank()!=0 || peer->GetGuildId() || peer->GetGuildIdInvited()) {receipt(id,false,"Guild participants unavailable or protected");continue;}
WorldPacket invite(CMSG_GUILD_INVITE,32); invite << std::string(peer->GetName());p->GetSession()->HandleGuildInviteOpcode(invite);
if(peer->GetGuildIdInvited()==p->GetGuildId()){WorldPacket accept(CMSG_GUILD_ACCEPT,0);peer->GetSession()->HandleGuildAcceptOpcode(accept);}
receipt(id,peer->GetGuildId()==p->GetGuildId(),"Native guild invitation processed");
} else if (kind=="leave") {
if (!free(p) || !safeGroup(p) || !p->GetGroup() || std::to_string(p->GetGroup()->GetId())!=arg) { receipt(id,false,"Party changed or protected"); continue; }
WorldPacket packet(CMSG_GROUP_DISBAND,0); p->GetSession()->HandleGroupDisbandOpcode(packet); receipt(id,!p->GetGroup(),"Native departure processed");
} else if (kind=="say") {
if (!nearby(p,peer,25) || arg.empty() || arg.size()>1000) { receipt(id,false,"Conversation participants unavailable nearby"); continue; }
p->Say(arg,p->GetTeam()==ALLIANCE?LANG_COMMON:LANG_ORCISH); receipt(id,true,"In-game speech sent");
} else if (kind=="intent") {
if (!p->IsAlive() || p->IsTaxiFlying() || p->IsInCombat() || !safeGroup(p)) { receipt(id,false,"Native recovery, travel or combat takes priority"); continue; }
PlayerbotAI* ai=p->GetPlayerbotAI(); bool accepted=false;
if (arg=="quest" || arg=="explore") { ai->ChangeStrategy("-stay,+rpg,+grind",BotState::BOT_STATE_NON_COMBAT); ai->DoNextAction(); accepted=true; }
else if (arg=="rest") { accepted=ai->DoSpecificAction("eat") || ai->DoSpecificAction("drink"); }
else if (arg=="trade") { accepted=ai->DoSpecificAction("repair") || ai->DoSpecificAction("sell") || ai->DoSpecificAction("buy"); }
else if (arg=="socialize") { p->HandleEmoteCommand(EMOTE_ONESHOT_WAVE); accepted=true; }
receipt(id,accepted,accepted?"Intention submitted to native AI; outcome requires observation":"Native AI did not accept this intention");
} else receipt(id,false,"Unsupported Classic command");
} while(q->NextRow());
}
void snapshot(Player* p) {
std::ostringstream out; std::string members, nearbyNames; Group* group=p->GetGroup();
if(group) for(GroupReference* r=group->GetFirstMember();r;r=r->next()) if(Player* m=r->getSource()) { if(!members.empty())members+=","; members+=m->GetName(); }
for(auto const& r:roster) if(r.first!=p->GetGUIDLow()) { Player* m=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,r.first)); if(managed(m)&&m->GetTeam()==p->GetTeam()&&m->GetMapId()==p->GetMapId()&&p->GetDistance(m)<=35) { if(!nearbyNames.empty())nearbyNames+=","; nearbyNames+=m->GetName(); } }
out << "{\"isBot\":true,\"characterGuid\":" << p->GetGUIDLow() << ",\"accountId\":" << p->GetSession()->GetAccountId() << ",\"name\":" << quoted(p->GetName())
<< ",\"level\":" << p->GetLevel() << ",\"race\":" << unsigned(p->getRace()) << ",\"class\":" << unsigned(p->getClass())
<< ",\"team\":" << (p->GetTeam()==ALLIANCE?0:1) << ",\"mapId\":" << p->GetMapId() << ",\"zoneId\":" << p->GetZoneId()
<< ",\"x\":" << p->GetPositionX() << ",\"y\":" << p->GetPositionY() << ",\"healthPct\":" << p->GetHealthPercent()
<< ",\"xp\":" << p->GetUInt32Value(PLAYER_XP) << ",\"observedAt\":" << std::time(nullptr)
<< ",\"isDead\":" << (p->IsAlive()?"false":"true") << ",\"isGhost\":" << (p->HasFlag(PLAYER_FLAGS,PLAYER_FLAGS_GHOST)?"true":"false")
<< ",\"inCombat\":" << (p->IsInCombat()?"true":"false") << ",\"casting\":" << (p->IsNonMeleeSpellCasted(false)?"true":"false")
<< ",\"taxi\":" << (p->IsTaxiFlying()?"true":"false") << ",\"humanControlled\":" << (managed(p)?"false":"true")
<< ",\"groupId\":" << quoted(group?std::to_string(group->GetId()):"") << ",\"groupNames\":" << quoted(members)
<< ",\"nearbyBots\":" << quoted(nearbyNames) << ",\"guildId\":" << p->GetGuildId() << ",\"guildRank\":" << p->GetRank()
<< ",\"quests\":[";
bool first=true;for(uint8 i=0;i<MAX_QUEST_LOG_SIZE;++i) { uint32 id=p->GetQuestSlotQuestId(i);if(id){if(!first)out<<',';out<<id;first=false;} }
out << ']'; if(p->GetPowerType()==POWER_MANA && p->GetMaxPower(POWER_MANA)) out << ",\"manaPct\":" << 100.0*p->GetPower(POWER_MANA)/p->GetMaxPower(POWER_MANA);
if(auto area=GetAreaEntryByAreaID(p->GetZoneId())) out << ",\"zoneName\":" << quoted(area->area_name[0]);
out << '}';
CharacterDatabase.PExecute("REPLACE INTO azerothai_snapshots (guid,at,payload) VALUES (%u,UNIX_TIMESTAMP(),'%s')",p->GetGUIDLow(),sql(out.str()).c_str());
}
}
uint32_t AzerothAIBoundDamage(Unit* victim,uint32_t damage) {
if(victim && victim->GetTypeId()==TYPEID_PLAYER && victim->GetGUIDLow()==armorGuid.load()) {
Player* p=static_cast<Player*>(victim);
if(p->GetSession()->GetAccountId()==armorAccount.load() && p->GetPlayerbotAI() && !p->GetPlayerbotAI()->IsRealPlayer()) return std::min(damage,p->GetHealth()>1?p->GetHealth()-1:0u);
}
return damage;
}
bool AzerothAIAllowBotLogin(uint32_t guid) {
if(!sConfig.GetBoolDefault("AzerothAI.RestrictBotLogin",false))return true;
auto count=CharacterDatabase.Query("SELECT COUNT(*) FROM azerothai_roster WHERE enabled=1");
if(!count || count->Fetch()[0].GetUInt32()>50)return false;
auto identity=CharacterDatabase.PQuery("SELECT c.guid FROM characters c JOIN azerothai_roster r ON r.guid=c.guid AND r.account_id=c.account AND r.name=c.name WHERE r.enabled=1 AND c.guid=%u",guid);
return bool(identity);
}
void AzerothAIUpdate(uint32_t diff) {
static uint32_t elapsed=0; elapsed+=diff;if(elapsed<5000)return;elapsed=0;
if(!sConfig.GetBoolDefault("AzerothAI.Enable",false)){armorGuid=0;armorAccount=0;return;}
static bool started=false; if(!started){if(!CharacterDatabase.DirectExecute("UPDATE azerothai_commands SET status='failed',result='Server restarted during execution; outcome unknown, command not replayed' WHERE status='processing'"))return;started=true;}
auto q=CharacterDatabase.Query("SELECT guid,account_id,name,plot_armor FROM azerothai_roster WHERE enabled=1 ORDER BY guid LIMIT 51");
if(!q || q->GetRowCount()>50){roster.clear();armorGuid=0;armorAccount=0;return;}
roster.clear();armorGuid=0;armorAccount=0;
do { Field* f=q->Fetch();Resident r{f[0].GetUInt32(),f[1].GetUInt32(),f[2].GetString(),f[3].GetBool()};if(!r.guid||!r.account)continue;roster.emplace(r.guid,r);if(r.armor && r.name=="Zaku"){armorAccount=r.account;armorGuid=r.guid;} }while(q->NextRow());
// Population is drawn exclusively from the explicit identity allowlist.
static std::map<uint32_t,time_t> loginAttempt;
for(auto const& pair:roster){auto const&r=pair.second;Player* p=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,r.guid));
if(!p){if(std::time(nullptr)-loginAttempt[r.guid]>=60){auto identity=CharacterDatabase.PQuery("SELECT guid FROM characters WHERE guid=%u AND account=%u AND name='%s'",r.guid,r.account,sql(r.name).c_str());if(identity){sRandomPlayerbotMgr.AddPlayerBot(r.guid,0);loginAttempt[r.guid]=std::time(nullptr);}}continue;}
if(identified(p))snapshot(p);
// Honor the configured native auto-learn policy even for externally
// maintained bot sessions. Do not grant spells through a second path.
static std::map<uint32_t,uint32_t> trainedLevel;
if(sPlayerbotAIConfig.autoLearnTrainerSpells && free(p) && trainedLevel[r.guid]!=p->GetLevel()){
if(p->GetPlayerbotAI()->DoSpecificAction("auto learn spell"))trainedLevel[r.guid]=p->GetLevel();
}
}
commands();
}
void AzerothAIEvent(Player* p, const char* kind, uint32_t value, uint32_t amount) {
if (!sConfig.GetBoolDefault("AzerothAI.Enable",false) || !managed(p)) return;
std::string eventKind(kind), column;
if(eventKind=="player.quest.complete")column="quests";
else if(eventKind=="player.quest.reward")column="rewarded";
else if(eventKind=="player.loot.item")column="lootItems";
else if(eventKind=="player.loot.money")column="lootedCopper";
else if(eventKind=="player.death")column="nativeDeaths";
else if(eventKind=="player.spell.learn")column="learnedSpells";
else if(eventKind=="player.resurrect")column="resurrections";
if(!column.empty())CharacterDatabase.PExecute("INSERT INTO azerothai_counters(guid,%s) VALUES(%u,%u) ON DUPLICATE KEY UPDATE %s=%s+%u",column.c_str(),p->GetGUIDLow(),amount,column.c_str(),column.c_str(),amount);
if(eventKind=="player.loot.item")CharacterDatabase.PExecute("INSERT INTO azerothai_counters(guid,lootEvents) VALUES(%u,1) ON DUPLICATE KEY UPDATE lootEvents=lootEvents+1",p->GetGUIDLow());
std::ostringstream out;
out << "{\"isBot\":true,\"characterGuid\":" << p->GetGUIDLow() << ",\"accountId\":" << p->GetSession()->GetAccountId()
<< ",\"name\":" << quoted(p->GetName()) << ",\"level\":" << p->GetLevel() << ",\"observedAt\":" << std::time(nullptr)
<< ",\"value\":" << value << ",\"amount\":" << amount;
if(auto area=GetAreaEntryByAreaID(p->GetZoneId()))out << ",\"zoneName\":" << quoted(area->area_name[0]);
out << '}';
CharacterDatabase.PExecute("INSERT INTO azerothai_events(at,kind,payload) VALUES(UNIX_TIMESTAMP(),'%s','%s')",sql(eventKind).c_str(),sql(out.str()).c_str());
}
void AzerothAIChat(Player* p, const std::string& message, Player* receiver) {
if(!sConfig.GetBoolDefault("AzerothAI.Enable",false) || !p || !p->IsInWorld() || (p->GetPlayerbotAI() && !p->GetPlayerbotAI()->IsRealPlayer()) || message.empty() || message.size()>500 || message[0]=='.' || message[0]=='!')return;
if(receiver && !managed(receiver))return;
std::string names;
for(auto const& r:roster){Player* bot=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,r.first));if(managed(bot) && bot->GetTeam()==p->GetTeam() && bot->GetMapId()==p->GetMapId() && bot->GetDistance(p)<=25){if(!names.empty())names+=",";names+=bot->GetName();}}
if(!receiver && names.empty())return;
std::ostringstream out;
out << "{\"isBot\":false,\"characterGuid\":" << p->GetGUIDLow() << ",\"accountId\":" << p->GetSession()->GetAccountId() << ",\"name\":" << quoted(p->GetName())
<< ",\"message\":" << quoted(message) << ",\"nearbyBots\":" << quoted(names) << ",\"receiver\":" << quoted(receiver?receiver->GetName():"") << ",\"receiverIsBot\":" << (receiver?"true":"false");
if(auto area=GetAreaEntryByAreaID(p->GetZoneId()))out << ",\"zoneName\":" << quoted(area->area_name[0]);
out << '}';
CharacterDatabase.PExecute("INSERT INTO azerothai_events(at,kind,payload) VALUES(UNIX_TIMESTAMP(),'%s','%s')",receiver?"player.whisper":"player.chat",sql(out.str()).c_str());
}
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include <cstdint>
class Unit;
void AzerothAIUpdate(uint32_t diff);
uint32_t AzerothAIBoundDamage(Unit* victim, uint32_t damage);
bool AzerothAIAllowBotLogin(uint32_t guid);
class Player;
void AzerothAIEvent(Player* player, const char* kind, uint32_t value = 0, uint32_t amount = 1);
#include <string>
void AzerothAIChat(Player* player, const std::string& message, Player* receiver = nullptr);
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Produce a validated level-one Vanilla roster; never modify live characters."""
import argparse
import collections
import json
import pathlib
import re
RACES = {"Human":1,"Orc":2,"Dwarf":3,"Night Elf":4,"Undead":5,"Tauren":6,"Gnome":7,"Troll":8}
CLASSES = {"Warrior":1,"Paladin":2,"Hunter":3,"Rogue":4,"Priest":5,"Shaman":7,"Mage":8,"Warlock":9,"Druid":11}
ALLOWED = {1:{1,2,4,5,8,9},2:{1,3,4,7,9},3:{1,2,3,4,5},4:{1,3,4,5,11},5:{1,4,5,8,9},6:{1,3,7,11},7:{1,4,8,9},8:{1,3,4,5,7,8}}
ALLIANCE = {"Human","Dwarf","Night Elf","Gnome","Draenei"}
def plan(bots):
if len(bots) != 50 or len({b['name'].lower() for b in bots}) != 50:
raise ValueError("Exactly 50 unique active identities required")
result = []
for b in bots:
if not re.fullmatch(r'[A-Za-z]{2,12}',b['name']):
raise ValueError("Invalid native character name")
race, role = b['race'], b['role']
team = 0 if race in ALLIANCE else 1
if role == 'Death Knight': role = 'Warrior'
if team == 0 and role == 'Shaman': role = 'Paladin'
if team == 1 and role == 'Paladin': role = 'Shaman'
if race == 'Draenei': race = 'Dwarf' if role in ('Hunter','Paladin','Warrior') else 'Human'
if race == 'Blood Elf': race = 'Tauren' if role == 'Shaman' else 'Undead'
race_id, class_id = RACES[race], CLASSES[role]
if class_id not in ALLOWED[race_id]:
raise ValueError(f"Invalid Vanilla combination: {b['name']} {race} {role}")
if (race in ALLIANCE) != (team == 0): raise ValueError('Faction changed')
result.append(dict(id=b['id'],name=b['name'],race=race,role=role,raceId=race_id,classId=class_id,team=team,level=1,
oldRace=b['race'],oldRole=b['role'],oldLevel=b['level']))
if collections.Counter(x['team'] for x in result) != {0:25,1:25}:
raise ValueError('Roster must be 25 Alliance and 25 Horde')
return result
if __name__ == '__main__':
parser=argparse.ArgumentParser()
parser.add_argument('active',type=pathlib.Path)
parser.add_argument('output',type=pathlib.Path)
args=parser.parse_args()
rows=plan(json.loads(args.active.read_text()))
args.output.write_text(json.dumps(rows,indent=2)+'\n')
print('Validated: 50 identities, 25 Alliance, 25 Horde, all start at level 1')
+48
View File
@@ -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())
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Prepare the isolated dashboard database after native identities are verified."""
import argparse
import copy
import datetime
import json
import pathlib
import sqlite3
import urllib.request
import uuid
def main(root,live):
stage=root/'app-staging'
bound=json.loads((root/'classic-roster-bound.json').read_text())
assert len(bound)==50 and len({b['characterGuid'] for b in bound})==50
with urllib.request.urlopen('http://127.0.0.1:8787/api/state',timeout=10) as response:state=json.load(response)
# This archive captures memories accumulated since the initial migration export.
(root/'wrath-state-before-dashboard-copy.json').write_text(json.dumps(state))
old={b['id']:b for b in state['bots']}
now=datetime.datetime.now(datetime.timezone.utc).isoformat()
bots=[]
for identity in bound:
bot=copy.deepcopy(old[identity['id']])
bot.update(name=identity['name'],race=identity['race'],role=identity['role'],level=1,zone='Awaiting Classic observation',status='offline',simulationBand='dormant',decisions=0)
for key in ['world','lastDecision','deathMemorySeenId','lastObservation','death']:bot.pop(key,None)
bot['autonomy']={'managed':True,'characterGuid':identity['characterGuid'],'accountId':identity['accountId']}
for memory in bot.get('memories',[]):memory['text']='[Historical memory from the previous Wrath realm; not current Classic progress] '+memory['text']
bot.setdefault('memories',[]).append({'id':str(uuid.uuid4()),'text':f"I am starting a new life at level 1 as a {identity['race']} {identity['role']} in Classic Azeroth. Older memories describe my previous realm.",'importance':10,'source':'observation','at':now})
bot['memories']=bot['memories'][-100:]
bots.append(bot)
state['bots']=bots;state['aleRosterAt']=0;state['revision']=state.get('revision',0)+1
state['events']=[];state.pop('operations',None);state['population']={}
state['settings']['population']['serverMax']=50
state['settings']['hardcore']['enabled']=False
state['community']={'feed':[],'evidence':[],'guilds':[],'partiesEnabled':True,'guildsEnabled':True}
ids={b['id'] for b in bots}
for home in state.get('homes',[]):home['residentIds']=[id for id in home.get('residentIds',[]) if id in ids]
source=list((live/'.wrangler/state/v3/d1').rglob('*.sqlite'))
candidates=[]
for path in source:
with sqlite3.connect(f'file:{path}?mode=ro',uri=True) as db:
if db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='realm_state'").fetchone():candidates.append(path)
if len(candidates)!=1:raise RuntimeError('Expected exactly one live realm database')
source=candidates[0];target=stage/source.relative_to(live)
if target.exists():raise RuntimeError('Staging database already exists; refusing to overwrite')
target.parent.mkdir(parents=True,exist_ok=True)
with sqlite3.connect(f'file:{source}?mode=ro',uri=True) as src,sqlite3.connect(target) as dst:
src.backup(dst)
tables={row[0] for row in dst.execute("SELECT name FROM sqlite_master WHERE type='table'")}
for table in ['ale_commands','ale_events','community_actions','community_clock','conversation_requests','realm_state_chunks']:
if table in tables:dst.execute('DELETE FROM '+table)
text=json.dumps(state,ensure_ascii=False,separators=(',',':'))
parts=[text[i:i+20000] for i in range(0,len(text),20000)];token=str(uuid.uuid4())
dst.execute('CREATE TABLE IF NOT EXISTS realm_state_chunks (token TEXT,part INTEGER,payload TEXT,PRIMARY KEY(token,part))')
dst.executemany('INSERT INTO realm_state_chunks VALUES (?,?,?)',[(token,i,p) for i,p in enumerate(parts)])
dst.execute("UPDATE realm_state SET revision=?,payload=? WHERE id='realm'",(state['revision'],json.dumps({'chunkToken':token,'parts':len(parts)})))
(root/'classic-initial-state.json').write_text(json.dumps(state))
print('Isolated dashboard prepared: 50 preserved personalities, native GUIDs rebound, historical memories labeled, live observations and queues reset')
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('--root',type=pathlib.Path,required=True);parser.add_argument('--live',type=pathlib.Path,required=True)
args=parser.parse_args();main(args.root.resolve(),args.live.resolve())
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Relay typed Classic commands and current native observations; no Lua execution."""
import argparse
import json
import os
import pathlib
import re
import subprocess
import time
import urllib.request
RACES={1:'Human',2:'Orc',3:'Dwarf',4:'Night Elf',5:'Undead',6:'Tauren',7:'Gnome',8:'Troll'}
CLASSES={1:'Warrior',2:'Paladin',3:'Hunter',4:'Rogue',5:'Priest',7:'Shaman',8:'Mage',9:'Warlock',11:'Druid'}
def validate(command):
if not isinstance(command,dict) or command.get('version')!=1 or command.get('backend')!='cmangos-classic': raise ValueError('Not a typed Classic command')
if command.get('kind') not in {'party','leave','recruit','intent','say','reply-say','reply-whisper'}:raise ValueError('Unsupported command kind')
for key in ['guid','accountId','peerGuid','peerAccountId','expiresAt']:
if type(command.get(key)) is not int or command[key]<0:raise ValueError('Invalid '+key)
if not command['guid'] or not command['accountId'] or not re.fullmatch('[A-Za-z]{2,12}',command.get('name','')):raise ValueError('Invalid actor identity')
if command['peerGuid'] and (not command['peerAccountId'] or not re.fullmatch('[A-Za-z]{2,12}',command.get('peerName',''))):raise ValueError('Invalid peer identity')
if command['kind'] in {'party','recruit','say','reply-say','reply-whisper'} and not command['peerGuid']:raise ValueError('Peer identity required')
if not isinstance(command.get('argument'),str) or len(command['argument'].encode())>1000:raise ValueError('Invalid argument')
if command['expiresAt']<time.time() or command['expiresAt']>time.time()+120:raise ValueError('Expired or invalid deadline')
if command['kind']=='intent' and command['argument'] not in {'quest','explore','rest','trade','socialize'}:raise ValueError('Unsupported intention')
return command
def main(root,base,env_file):
credentials=json.loads((root/'database-credentials.json').read_text())
values={}
for line in env_file.read_text().splitlines():
if '=' in line and not line.lstrip().startswith('#'):
key,value=line.split('=',1);values[key.strip()]=value.strip().strip('\"\'')
token=values['ALE_BRIDGE_TOKEN']
env=dict(os.environ,MYSQL_PWD=credentials['root'])
def mysql(sql):
result=subprocess.run(['docker','exec','-i','-e','MYSQL_PWD','azerothai-classic-db','mysql','-uroot','-N','-B','--raw','classiccharacters'],input=sql,text=True,capture_output=True,env=env,timeout=10)
if result.returncode:raise RuntimeError('Classic database query failed')
return [line.split('\t') for line in result.stdout.strip().splitlines()]
def quote(text):return "CONVERT(UNHEX('"+str(text).encode().hex()+"') USING utf8mb4)"
def request(path,payload=None):
body=json.dumps(payload or {}).encode()
req=urllib.request.Request(base.rstrip('/')+path,data=body,headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'})
with urllib.request.urlopen(req,timeout=10) as response:return response.headers,response.read().decode()
def complete(id,ok,result):request('/api/bridge/ale/result',{'id':id,'ok':ok,'result':result})
last_snapshot=0;last_cleanup=0;last_outcomes=0
while True:
try:
now=int(time.time())
if now-last_snapshot>=5:
rows=mysql('SELECT HEX(payload) FROM azerothai_snapshots WHERE at>=UNIX_TIMESTAMP()-15 ORDER BY guid LIMIT 51')
if len(rows)>50:raise RuntimeError('Classic snapshot population exceeds 50')
payloads=[]
for row in rows:
item=json.loads(bytes.fromhex(row[0]))
item['raceName']=RACES.get(item.get('race'),'Unknown')
item['className']=CLASSES.get(item.get('class'),'Unknown')
payloads.append(item)
roster={'roster':True,'observedAt':now,'characterGuids':[p['characterGuid'] for p in payloads]}
# Rotate eligible nearby pairs; normal app cooldowns bound conversations.
pairs=[]
available=lambda p:not any(p.get(k) for k in ['humanControlled','isDead','isGhost','inCombat','taxi','casting'])
for p in payloads:
if not available(p):continue
for q in payloads:
if p['characterGuid']>=q['characterGuid'] or not available(q):continue
if p['team']==q['team'] and p['mapId']==q['mapId'] and (p['x']-q['x'])**2+(p['y']-q['y'])**2<=625:pairs.append((p,q))
if pairs:
a,b=pairs[(now//10)%len(pairs)];roster.update(socialA=a['name'],socialB=b['name'],zoneName=a.get('zoneName',''))
request('/api/bridge/ale/snapshots',{'payloads':payloads+[roster]});last_snapshot=now
for event_id,kind,payload in mysql("SELECT id,kind,HEX(payload) FROM azerothai_events WHERE delivered=0 ORDER BY id LIMIT 30"):
data=json.loads(bytes.fromhex(payload));data['nativeEventId']='classic:'+event_id
request('/api/bridge/ale/event',{'kind':kind,'payload':data})
mysql('UPDATE azerothai_events SET delivered=1 WHERE id='+str(int(event_id)))
if now-last_outcomes>=60:
rows=mysql("SELECT r.guid,r.name,COALESCE(c.quests,0),COALESCE(c.rewarded,0),COALESCE(c.lootEvents,0),COALESCE(c.lootItems,0),COALESCE(c.lootedCopper,0),COALESCE(c.nativeDeaths,0) FROM azerothai_roster r LEFT JOIN azerothai_counters c ON c.guid=r.guid WHERE r.enabled=1")
outcomes=[]
for guid,name,quests,rewarded,events,items,copper,deaths in rows:
outcomes.append(dict(guid=int(guid),name=name,quests=int(quests),rewarded=int(rewarded),lootEvents=int(events),lootItems=int(items),lootedCopper=int(copper),nativeDeaths=int(deaths),abandoned=0,partyKills=0))
body=json.dumps(dict(at=now,recoveries=[],outcomes=outcomes)).encode()
req=urllib.request.Request(base.rstrip('/')+'/api/realm-insights',data=body,headers={'Authorization':'Bearer '+values['AUTONOMY_TOKEN'],'Content-Type':'application/json'})
with urllib.request.urlopen(req,timeout=10) as response:response.read()
last_outcomes=now
headers,body=request('/api/bridge/ale/poll')
id=headers.get('X-AzerothAI-Command')
if id:
if not re.fullmatch('[0-9a-fA-F-]{36}',id):raise ValueError('Invalid command ID')
try:c=validate(json.loads(body))
except (ValueError,TypeError) as error:complete(id,False,str(error))
else:
mysql(f"INSERT IGNORE INTO azerothai_commands(id,guid,account_id,name,kind,argument,peer_guid,peer_account_id,peer_name,created_at,expires_at) VALUES ({quote(id)},{c['guid']},{c['accountId']},{quote(c['name'])},{quote(c['kind'])},{quote(c['argument'])},{c['peerGuid']},{c['peerAccountId']},{quote(c['peerName'])},{c['expiresAt']-90},{c['expiresAt']})")
for id,status,result in mysql("SELECT id,status,HEX(COALESCE(result,'')) FROM azerothai_commands WHERE delivered=0 AND status IN ('completed','failed') LIMIT 16"):
complete(id,status=='completed',bytes.fromhex(result).decode());mysql('UPDATE azerothai_commands SET delivered=1 WHERE id='+quote(id))
mysql("UPDATE azerothai_commands SET status='failed',result='Expired before native execution' WHERE status='pending' AND expires_at<UNIX_TIMESTAMP()")
if now-last_cleanup>3600:
mysql("DELETE FROM azerothai_events WHERE delivered=1 AND at<UNIX_TIMESTAMP()-604800")
mysql("DELETE FROM azerothai_commands WHERE delivered=1 AND created_at<UNIX_TIMESTAMP()-604800");last_cleanup=now
except Exception as error:
# Do not print request objects, credentials, or native SQL arguments.
print(type(error).__name__+': '+str(error).replace(token,'[redacted]').replace(credentials['password'],'[redacted]').replace(credentials['root'],'[redacted]'),flush=True)
time.sleep(1)
if __name__=='__main__':
parser=argparse.ArgumentParser();parser.add_argument('--root',type=pathlib.Path,required=True);parser.add_argument('--url',required=True);parser.add_argument('--env-file',type=pathlib.Path,required=True)
args=parser.parse_args();main(args.root,args.url,args.env_file)
+49
View File
@@ -0,0 +1,49 @@
-- Apply only to the isolated Classic character database.
CREATE TABLE IF NOT EXISTS azerothai_roster (
guid INT UNSIGNED PRIMARY KEY,
account_id INT UNSIGNED NOT NULL,
name VARCHAR(12) NOT NULL UNIQUE,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
plot_armor BOOLEAN NOT NULL DEFAULT FALSE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS azerothai_commands (
id VARCHAR(36) PRIMARY KEY,
guid INT UNSIGNED NOT NULL,
account_id INT UNSIGNED NOT NULL,
name VARCHAR(12) NOT NULL,
kind VARCHAR(20) NOT NULL,
argument TEXT NOT NULL,
peer_guid INT UNSIGNED NOT NULL DEFAULT 0,
peer_account_id INT UNSIGNED NOT NULL DEFAULT 0,
peer_name VARCHAR(12) NOT NULL DEFAULT '',
expires_at BIGINT UNSIGNED NOT NULL,
created_at BIGINT UNSIGNED NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
result TEXT,
delivered BOOLEAN NOT NULL DEFAULT FALSE,
INDEX pending (status,created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS azerothai_snapshots (
guid INT UNSIGNED PRIMARY KEY,
at BIGINT UNSIGNED NOT NULL,
payload MEDIUMTEXT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS azerothai_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
at BIGINT UNSIGNED NOT NULL,
kind VARCHAR(100) NOT NULL,
payload TEXT NOT NULL,
delivered BOOLEAN NOT NULL DEFAULT FALSE,
INDEX delivery (delivered,id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS azerothai_counters (
guid INT UNSIGNED PRIMARY KEY,
quests BIGINT UNSIGNED NOT NULL DEFAULT 0,
rewarded BIGINT UNSIGNED NOT NULL DEFAULT 0,
lootEvents BIGINT UNSIGNED NOT NULL DEFAULT 0,
lootItems BIGINT UNSIGNED NOT NULL DEFAULT 0,
lootedCopper BIGINT UNSIGNED NOT NULL DEFAULT 0,
nativeDeaths BIGINT UNSIGNED NOT NULL DEFAULT 0,
learnedSpells BIGINT UNSIGNED NOT NULL DEFAULT 0,
resurrections BIGINT UNSIGNED NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Finish isolated Classic prerequisites. Never starts a realm or edits live data."""
import argparse
import hashlib
import json
import pathlib
import shutil
import subprocess
import time
parser = argparse.ArgumentParser()
parser.add_argument('--root', required=True)
args = parser.parse_args()
root = pathlib.Path(args.root).resolve()
def status(stage, **extra):
value = {'at': time.time(), 'stage': stage, 'cutover': False, **extra}
temporary = root / 'prerequisites-status.tmp'
temporary.write_text(json.dumps(value, indent=2))
temporary.replace(root / 'prerequisites-status.json')
print(stage, flush=True)
def run(*command):
return subprocess.check_output(command, text=True).strip()
try:
status('waiting for Classic compilation and client transfer')
deadline = time.monotonic() + 6 * 3600
while True:
state = json.loads(run('docker', 'inspect', 'azerothai-classic-build'))[0]['State']
if not state['Running']:
if state['ExitCode'] != 0:
raise RuntimeError('Classic build failed; inspect docker logs azerothai-classic-build')
break
if time.monotonic() > deadline:
raise RuntimeError('Classic build exceeded six-hour observation window')
time.sleep(30)
manifest = json.loads((root / 'client-manifest.json').read_text())
while not all((root / 'client' / f).exists() and (root / 'client' / f).stat().st_size == v['size'] for f, v in manifest.items()):
if time.monotonic() > deadline:
raise RuntimeError('Client transfer incomplete after six hours')
time.sleep(30)
status('verifying transferred client archives')
for filename, expected in manifest.items():
with (root / 'client' / filename).open('rb') as stream:
actual = hashlib.file_digest(stream, 'sha256').hexdigest()
if actual != expected['sha256']:
raise RuntimeError('Client archive checksum mismatch: ' + filename)
if shutil.disk_usage(root).free < 8 * 1024**3:
raise RuntimeError('Less than 8 GiB free for extracted Vanilla data')
output = root / 'extracted'; output.mkdir(exist_ok=True)
status('extracting Vanilla maps and navigation data; realm not switched')
with (root / 'data-extraction.log').open('w') as log:
subprocess.run([
'docker', 'run', '--name', 'azerothai-classic-extract', '--cpus', '2', '--memory', '4g',
'--user', '1000:1000', '-i', '-v', str(root) + ':/work', '-w', '/work/run/bin/tools',
'azerothai-classic-builder:local', 'bash', './ExtractResources.sh', '/work/client', '/work/extracted'
], input='y\n2\n\nn\nn\n\n', text=True, stdout=log, stderr=subprocess.STDOUT, check=True)
counts = {name: sum(1 for f in (output / name).rglob('*') if f.is_file()) for name in ('dbc', 'maps', 'vmaps', 'mmaps')}
if not all(counts.values()):
raise RuntimeError('An extracted data directory is empty: ' + str(counts))
status('prerequisites extracted; database, connector, roster and gameplay validation remain', files=counts)
except Exception as error:
status('blocked', error=str(error))
raise
+37
View File
@@ -0,0 +1,37 @@
# Grounded decisions and gameplay evaluation
The live model receives up to eight active quest-log entries from this server's
quest_template table, nearby hostile units within 35 yards, health/mana, fresh
inventory readiness, eligible nearby party candidates, and recent measured
outcomes. Static quest titles are cached in ALE. Live facts remain snapshots;
missing/stale readings are not replaced with invented details.
Fully recovered bots cannot select rest when another allowed action exists.
Low mana joins low health as a recovery override. Dead/ghost, human-controlled,
and taxi residents are excluded from intention selection. Native survival,
trainer, human-party and movement guards remain authoritative.
Socialize requests a guarded native party invitation when an eligible candidate
is present and neither participant has a pending/recent invitation. Otherwise it
requests the normal social strategy. Confirmed membership, rather than narrative
claims, verifies party formation. Trade also requests normal supply purchasing
and repair, never spawned items or free repairs.
Each resident retains 12 intention checks. Later fresh snapshots can verify
travel, health/mana recovery, XP/level progress, supply/equipment improvement,
or the intended companion's party membership. XP is not called a quest turn-in;
inventory improvements are not called verified purchases. Death or human control
interrupts a check. Ten minutes without matching observations is unverified,
not proof the engine failed. Confirmed dialogue has its own existing counters.
The monitor exposes sampled online/active bot-hours, native death deltas including
normal resurrection, quest turn-ins per bot-hour, and observed/unverified/
interrupted intention results. Rates start with this evaluation deployment and
exclude collection gaps over two minutes. Native deaths use retained database
records; retention rollover can undercount. Quest/XP rate and deaths must be read
together: idling is not successful survival. Aggregates alone are insufficient to
compare different levels/classes; inspect per-bot gameplay tables as well.
Run `python3 scripts/evaluate-gameplay.py` on the mini PC for a read-only summary.
Compare the first full day, investigate failures, and retain the existing model.
No fine-tuning or paid compute is started by this change.
+11 -3
View File
@@ -99,14 +99,22 @@ async function deliverEvents(){
FROM acore_auth.azerothai_event_outbox
WHERE delivered_at IS NULL AND kind='player.snapshot'
AND created_at >= NOW() - INTERVAL 90 SECOND
ORDER BY id ASC LIMIT ${Math.max(1,Math.floor(EVENT_BATCH/2))}`);
ORDER BY id ASC LIMIT 64`);
if(fresh.length){
const payloads=fresh.map(([, ,hex])=>JSON.parse(Buffer.from(hex,'hex').toString('utf8')));
const response=await backend('/api/bridge/ale/snapshots',{
method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({payloads})
});
if(!response.ok)throw new Error(`snapshot batch HTTP ${response.status}: ${await response.text()}`);
mysql(`UPDATE acore_auth.azerothai_event_outbox SET delivered_at=NOW(6) WHERE id IN (${fresh.map(row=>Number(row[0])).join(',')})`);
}
const batch=rows(`SELECT id,HEX(kind),HEX(payload)
FROM acore_auth.azerothai_event_outbox
WHERE delivered_at IS NULL
${fresh.length?`AND id NOT IN (${fresh.map(row=>Number(row[0])).join(',')})`:''}
ORDER BY CASE WHEN kind IN ('player.permanent-death','bridge.ready') THEN 0 WHEN kind IN ('player.whisper','player.chat') AND created_at >= NOW() - INTERVAL 90 SECOND THEN 1 ELSE 2 END, id
LIMIT ${Math.max(1,EVENT_BATCH-fresh.length)}`);
for(const [id,kindHex,payloadHex] of [...fresh,...batch]){
LIMIT ${EVENT_BATCH}`);
for(const [id,kindHex,payloadHex] of batch){
const kind=Buffer.from(kindHex||'','hex').toString('utf8');
const payloadText=Buffer.from(payloadHex||'','hex').toString('utf8');
let payload;
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env python3
"""Read-only checkpoint for the grounded-decision trial. No keys or player whispers."""
import collections,json,time,urllib.request
from pathlib import Path
base='http://127.0.0.1:8787'
def get(path):
with urllib.request.urlopen(base+path,timeout=20) as r:return json.load(r)
s=get('/api/state');insights=get('/api/realm-insights');now=time.time()
bots=[b for b in s['bots'] if b.get('status')=='online' and b.get('autonomy',{}).get('managed')]
checks=[c for b in bots for c in b.get('autonomy',{}).get('intentChecks',[])]
report={'checkedAt':now,'online':len(bots),'teams':dict(collections.Counter(b.get('world',{}).get('team') for b in bots)),
'freshObservations':sum(0<=now-b.get('world',{}).get('at',0)<90 for b in bots),
'groupedBots':sum(bool(b.get('world',{}).get('groupId')) for b in bots),
'questKnowledge':sum(bool(b.get('world',{}).get('questBrief')) for b in bots),
'evaluation':insights.get('evaluation'),'recentIntentResults':dict(collections.Counter(c['status'] for c in checks)),
'currentActivity':dict(collections.Counter(b['label'] for b in insights.get('activity',[]))),
'zaku':[{k:b.get(k) for k in ('name','level','zone','lastDecision')} for b in bots if b['name']=='Zaku'],
'limitations':['Rates use fresh sampled bot-hours; collection gaps excluded.','Native death deltas use retained death records; retention can undercount at rollover.','An observed outcome following an intention does not establish causation.','One day is an initial comparison, not proof of long-term reliability.']}
print(json.dumps(report,indent=2))
+6 -2
View File
@@ -1,6 +1,6 @@
"""Read native counters and profiler output; return bounded public measurements."""
import json,subprocess,time,math
COUNTERS=('quests','abandoned','lootEvents','lootItems','partyKills','rewarded','lootedCopper')
COUNTERS=('quests','abandoned','lootEvents','lootItems','partyKills','rewarded','lootedCopper','nativeDeaths')
def normalize_performance(raw):
def number(v):
try:
@@ -28,9 +28,13 @@ def collect(sql,state):
for line in sql('SELECT guid,COUNT(*) FROM acore_characters.character_queststatus_rewarded WHERE guid IN ('+guids+') GROUP BY guid').splitlines():
guid,count=map(int,line.split('\t'));rewarded[guid]=count
money={int(g):int(c) for g,c in (line.split('\t') for line in sql('SELECT guid,looted_copper FROM acore_auth.azerothai_currency').splitlines())}
deaths={}
for line in sql('SELECT guid,account_id,name,COUNT(*) FROM acore_auth.azerothai_death_experience GROUP BY guid,account_id,name').splitlines():
g,account,name,count=line.split('\t');g=int(g)
if g in allowed and allowed[g]['name']==name and allowed[g]['autonomy'].get('accountId')==int(account):deaths[g]=int(count)
result=[]
for guid,b in allowed.items():
counters=rows.get(guid,dict.fromkeys(COUNTERS,0));counters['rewarded']=rewarded.get(guid,0);counters['lootedCopper']=money.get(guid,0)
counters=rows.get(guid,dict.fromkeys(COUNTERS,0));counters['rewarded']=rewarded.get(guid,0);counters['lootedCopper']=money.get(guid,0);counters['nativeDeaths']=deaths.get(guid,0)
result.append({'guid':guid,'name':b['name'],**counters})
return result
+1 -1
View File
@@ -3,4 +3,4 @@ import {writeFileSync,mkdirSync} from 'node:fs';
const result=spawnSync(process.execPath,['node_modules/typescript/bin/tsc','core/state-chunks.ts','core/realm-outcomes.ts','core/realm-insights.ts','core/service.ts','core/seed.ts','core/ale.ts','core/conversation.ts','core/autonomy.ts','--types','@cloudflare/workers-types,node','--outDir','.test-build','--module','commonjs','--target','es2022','--moduleResolution','node','--esModuleInterop','--skipLibCheck','--strict'],{stdio:'inherit'});
if(result.status)process.exit(result.status);
mkdirSync('.test-build',{recursive:true});writeFileSync('.test-build/package.json','{"type":"commonjs"}');
process.exit(spawnSync(process.execPath,['--test','tests/realm-outcomes.test.cjs','tests/realm-insights.test.cjs','tests/core.test.cjs','tests/hardcore.test.cjs','tests/living-world.test.cjs','tests/ale.test.cjs','tests/brains.test.cjs','tests/conversation.test.cjs','tests/autonomy.test.cjs','tests/community.test.cjs'],{stdio:'inherit'}).status??1);
process.exit(spawnSync(process.execPath,['--test','tests/realm-outcomes.test.cjs','tests/realm-insights.test.cjs','tests/core.test.cjs','tests/classic.test.cjs','tests/hardcore.test.cjs','tests/living-world.test.cjs','tests/ale.test.cjs','tests/brains.test.cjs','tests/conversation.test.cjs','tests/autonomy.test.cjs','tests/community.test.cjs'],{stdio:'inherit'}).status??1);
+11
View File
@@ -34,3 +34,14 @@ test('surface inventory includes every documented ALE class family',()=>{
assert.equal(ALE_SURFACE.playerbotClasses.length,7);
assert.equal(ALE_SURFACE.modelAccess,'none');
});
test('snapshot batch saves once and does not commit a partial invalid roster',async()=>{
const {applyAuthoritativeAleSnapshots}=require('../.test-build/service.js');
const {seed}=require('../.test-build/seed.js');
let state=seed(),writes=0;
const store={load:async()=>structuredClone(state),save:async(s,r)=>{assert.equal(r,state.revision);writes++;state=s;}};
await applyAuthoritativeAleSnapshots(store,[{roster:true,observedAt:100,characterGuids:[]},{roster:true,observedAt:101,characterGuids:[]}]);
assert.equal(writes,1);assert.equal(state.aleRosterAt,101);
await assert.rejects(()=>applyAuthoritativeAleSnapshots(store,[{roster:true,observedAt:102,characterGuids:[]},{roster:true,observedAt:103,characterGuids:[-1]}]));
assert.equal(writes,1);assert.equal(state.aleRosterAt,101);
});
+26
View File
@@ -35,3 +35,29 @@ test('rest requests never enable indefinite stay',()=>{
const source=compileLivePlayerbotIntent('A',{action:'rest',goal:'Recover',reason:'Tired'});
assert.doesNotMatch(source,/\+stay/);assert.match(source,/-stay/);assert.match(source,/GetHealthPct/);assert.match(source,/AzerothAITrainingOverride/);
});
test('fresh full recovery removes unnecessary rest but stale or low mana does not',()=>{
const {availableIntentActions,decisionContext}=require('../.test-build/autonomy.js');
const bot={personality:{activities:['quest','rest']},world:{at:Date.now()/1000,healthPct:100,manaPct:100,questBrief:'A real quest',threatBrief:'Wolf level 7',readinessAt:Date.now()/1000,foodCount:0,drinkCount:0}};
assert.deepEqual(availableIntentActions(bot),['quest']);assert.match(decisionContext(bot),/A real quest/);assert.match(decisionContext(bot),/Wolf level 7/);
bot.world.manaPct=20;assert.deepEqual(availableIntentActions(bot),['quest','rest']);
bot.world.at-=200;assert.deepEqual(availableIntentActions(bot),['quest','rest']);assert.doesNotMatch(decisionContext(bot),/Wolf/);
});
test('intent outcomes require fresh matching evidence, not just accepted commands',()=>{
const {startIntentCheck,observeIntentChecks}=require('../.test-build/intent-evidence.js');
const now=Date.now()/1000,b={level:10,world:{at:now,x:0,y:0,mapId:0,healthPct:50,manaPct:10,groupNames:[],xp:100},autonomy:{}};
const check=startIntentCheck(b,{action:'quest'},'q',now);b.autonomy.intentChecks=[check];
b.world.x=30;b.world.at=now+30;observeIntentChecks(b);assert.equal(check.status,'pending','Walking is not quest progress');
b.world.xp=110;observeIntentChecks(b);assert.equal(check.status,'observed');
const rest=startIntentCheck(b,{action:'rest'},'r',now+30);b.autonomy.intentChecks=[rest];b.world.healthPct=100;b.world.manaPct=100;
observeIntentChecks(b);assert.equal(rest.status,'pending','Same snapshot cannot prove recovery');
b.world.at=now+60;b.world.inCombat=false;observeIntentChecks(b);assert.equal(rest.status,'observed');
const party=startIntentCheck(b,{action:'socialize'},'p',now+60,'Peer');b.autonomy.intentChecks=[party];b.world.at=now+90;b.world.nearby=['Peer'];observeIntentChecks(b);assert.equal(party.status,'pending');
b.world.groupId='g';b.world.groupNames=['Peer'];observeIntentChecks(b);assert.equal(party.status,'observed');
});
test('death interrupts intentions and unverified work times out honestly',()=>{
const {startIntentCheck,observeIntentChecks}=require('../.test-build/intent-evidence.js');const n=Date.now()/1000;
const b={level:10,world:{at:n,x:0,y:0,mapId:0,groupNames:[],xp:100},autonomy:{}};
const c=startIntentCheck(b,{action:'trade'},'t',n);b.autonomy.intentChecks=[c];b.world.at=n+601;observeIntentChecks(b);assert.equal(c.status,'no_progress');
const d=startIntentCheck(b,{action:'quest'},'q',n+601);b.autonomy.intentChecks=[d];b.world.at=n+630;b.world.isGhost=true;observeIntentChecks(b);assert.equal(d.status,'interrupted');
});
+30
View File
@@ -0,0 +1,30 @@
const {test}=require('node:test');
const assert=require('node:assert/strict');
const {classicCommand,classicMode}=require('../.test-build/classic.js');
const actor={name:'Zaku',autonomy:{managed:true,characterGuid:7,accountId:3}};
test('Classic transport requires explicit backend selection',()=>{
assert.equal(classicMode({}),false);
assert.equal(classicMode({GAME_BACKEND:'cmangos-classic'}),true);
});
test('Classic command rejects absent, zero and unmanaged native identities',()=>{
for(const autonomy of [undefined,{managed:false,characterGuid:7,accountId:3},{managed:true,characterGuid:0,accountId:3},{managed:true,characterGuid:7,accountId:-1}])
assert.throws(()=>classicCommand('intent',{...actor,autonomy},'quest'));
assert.throws(()=>classicCommand('party',actor));
});
test('Classic peer commands bind both accounts and preserve literal speech',()=>{
const peer={name:'Alany',autonomy:{managed:true,characterGuid:8,accountId:4}};
const message='hello "friend"; return 1';
const command=JSON.parse(classicCommand('say',actor,message,peer));
assert.equal(command.argument,message);
assert.equal(command.guid,7);assert.equal(command.accountId,3);
assert.equal(command.peerGuid,8);assert.equal(command.peerAccountId,4);
assert.ok(command.expiresAt>Date.now()/1000);
});
test('Classic human replies require authoritative recipient identity',()=>{
const {classicPlayerReply}=require('../.test-build/classic.js');
for(const request of [{speakerName:'Endami',channel:'whisper'},{speakerName:'Endami',channel:'say',speakerGuid:-1,speakerAccountId:1}])assert.throws(()=>classicPlayerReply(actor,request,'Hello'));
const reply=JSON.parse(classicPlayerReply(actor,{speakerName:'Endami',channel:'whisper',speakerGuid:51,speakerAccountId:11},'Hello'));
assert.equal(reply.kind,'reply-whisper');assert.equal(reply.peerGuid,51);assert.equal(reply.peerAccountId,11);assert.equal(reply.peerName,'Endami');
assert.equal(reply.guid,7);assert.equal(reply.argument,'Hello');
});
+7
View File
@@ -54,3 +54,10 @@ test('adventuring companions stay within five levels',()=>{
assert.equal(partyDecision(a,b).accepted,false);
b.level=a.level+5;assert.equal(partyDecision(a,b).accepted,true);
});
test('party intentions exclude ghosts and protect unknown party members',()=>{
const {intentCompanions}=require('../.test-build/community.js');const a=bot('A'),b=bot('B'),s={bots:[a,b],community:{partiesEnabled:true}};
assert.deepEqual(intentCompanions(s,a).map(x=>x.name),['B']);
b.world.isGhost=true;assert.equal(intentCompanions(s,a).length,0);b.world.isGhost=false;
a.world.groupId='g';a.world.groupNames=['A','Unknown'];assert.equal(intentCompanions(s,a).length,0);
});
+14
View File
@@ -34,3 +34,17 @@ test('trainer activity reports pending work and verified learning separately',()
const a=activityReason(b,now);assert.equal(a.label,'Visiting class trainer');assert.match(a.detail,/3 identified spells pending, 2 verified trainer purchases/);
b.world.inCombat=true;assert.equal(activityReason(b,now).label,'In combat');
});
test('exposure excludes stale snapshots and gaps; outcome evidence counts once',()=>{
const s=state();const first=observeInsights(s,undefined,now);
for(const b of s.bots)b.world.at=now+60;
s.bots[0].autonomy.intentChecks=[{id:'q',action:'quest',status:'observed',checkedAt:now+30,detail:'XP gained'}];
const next=observeInsights(s,first.cursor,now+60);
assert.equal(next.events.filter(e=>e.kind==='onlineSeconds').reduce((n,e)=>n+e.amount,0),120);
assert.equal(next.events.filter(e=>e.kind==='intentObserved').length,1);
assert.equal(observeInsights(s,next.cursor,now+90).events.length,0);
for(const b of s.bots)b.world.at=now+185;
assert.equal(observeInsights(s,next.cursor,now+185).events.filter(e=>e.kind==='onlineSeconds').reduce((n,e)=>n+e.amount,0),250);
for(const b of s.bots)b.world.at=now+600;
assert.equal(observeInsights(s,next.cursor,now+600).events.filter(e=>e.kind==='onlineSeconds').length,0);
});
+5
View File
@@ -8,3 +8,8 @@ test('resolved old recovery failures do not remain actionable',()=>{const b={id:
test('currency formats copper and remains independent of item counts and historical baselines',()=>{const {formatCopper}=require('../.test-build/realm-outcomes.js');assert.equal(formatCopper(12345),'1g 23s 45c');assert.equal(formatCopper(0),'0g 0s 0c');const first=outcomeChanges([{...row,lootedCopper:100}],{at:1,startedAt:1,rows:[row]},2);assert.equal(first.events.length,0);const next=outcomeChanges([{...row,lootedCopper:250}],first.baseline,3);assert.deepEqual(next.events.map(e=>[e.kind,e.amount]),[['lootedCopper',150]]);});
test('large state chunks preserve Unicode and all history within per-value limits',()=>{const {splitState}=require('../.test-build/state-chunks.js');const input={history:'🎮'.repeat(600000)};const chunks=splitState(input);assert.ok(chunks.length>1);assert.deepEqual(JSON.parse(chunks.join('')),input);for(const c of chunks)assert.ok(Buffer.byteLength(c)<512*1024);});
test('normal death counts establish a baseline and count subsequent native deaths',()=>{
const first=outcomeChanges([{...row,nativeDeaths:100}],{at:1,startedAt:1,rows:[row]},2);assert.equal(first.events.length,0);
const next=outcomeChanges([{...row,nativeDeaths:103}],first.baseline,3);assert.equal(next.events.find(e=>e.kind==='nativeDeaths').amount,3);
});
+6 -2
View File
@@ -15,8 +15,8 @@ function make(id,name)
function p:GetGUIDLow()return self.id end;function p:GetName()return self.name end;function p:GetAccountId()return 10+self.id end
function p:IsPlayerbot()return not self.human end;function p:IsDead()return self.dead end;function p:GetBotAI()return self.ai end
function p:GetPowerType()return self.powerType or 1 end;function p:GetPowerPct()return self.mana or 100 end;function p:GetAttackers()return self.attackers or {} end;function p:GetHealthPct()return self.hp end;function p:IsInCombat()return self.combat end
function p:HasUnitState(state)assert(state==0x00000100);return self.taxi end;function p:IsCasting()return self.cast end;function p:IsRooted()return false end
function p:GetX()return self.x or 0 end;function p:GetY()return 0 end
function p:HasUnitState(state)assert(state==0x00000100);return self.taxi end;function p:IsMoving()return self.moving end;function p:GetMovementType()return self.motion or 0 end;function p:MoveExpire()self.expired=true;self.motion=0 end;function p:MoveFleeing(t,ms)self.fleeTarget=t;self.motion=9;self.fleeDuration=ms end;function p:IsCasting()return self.cast end;function p:IsRooted()return false end
function p:GetX()return self.x or 0 end;function p:GetY()return 0 end;function p:GetZ()return 0 end;function p:MoveTo(id,x,y,z,path)self.escape={id=id,x=x,y=y,z=z,path=path} end
function p:GetUnfriendlyUnitsInRange()return self.friendly and {} or self.selected and {self.selected} or {} end;function p:GetSelection()return self.selected end;function p:StopSpellCast()self.cast=false;self.interrupted=true end
function p:AttackStop()self.attackStopped=true end;function p:MoveStop()self.waited=true end
function p:GetGroup()return self.group end;function p:GetVictim()return self.enemy end;function p:GetLevel()return 5 end
@@ -69,3 +69,7 @@ print('Cohesion suppresses competing work, keeps catch-up hysteresis, yields to
l=new();l.execute("A.strategies={stay=true};step(0);assert(not A.strategies.stay,'Fully recovered autonomous resident must not remain in stay')")
l=new();l.execute("group();B.distance=50;step(0);step(40);assert(B.master==nil,'No-progress regroup must release its owned master');assert(not AzerothAISurvivalOverride(B));step(30);assert(B.master==nil,'Backoff must prevent immediate retry')")
print('Recovered stay cleared and non-progressing regroup bounded')
l=new();l.execute("A.combat=true;A.hp=20;B.x=5;A.attackers={B};function A.ai:DoSpecificAction(s)table.insert(calls,s);return false end;step(0);assert(A.escape.x==-20 and A.escape.path==true);A.escape=nil;step(8);assert(A.escape==nil,'Native fallback must honor cooldown');step(8);assert(A.escape.x==-20)")
l=new();l.execute("A.combat=true;A.hp=20;A.controlled=true;B.x=5;A.attackers={B};function A.ai:DoSpecificAction()return false end;step(0);assert(A.escape==nil)")
print('Rejected flee uses bounded path away from threat, with cooldown and human protection')