Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f91724ebfa | ||
|
|
3019acb9c1 | ||
|
|
0d0edc6599 | ||
|
|
6f9cdcb5a4 | ||
|
|
d7d4e899f1 | ||
|
|
f5099cd338 | ||
|
|
02d21abcbd | ||
|
|
58b2019015 | ||
|
|
1f9ad52d07 | ||
|
|
502cb1e4fd |
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { z } from 'zod';
|
||||
import { requireAleOperator,rawAleEnabled } from '@/core/ale-auth';
|
||||
import {runGM} from '@/core/gm';
|
||||
import { compileAleOperatorRequest } from '@/core/ale';
|
||||
import { queueAleCommand } from '@/core/ale-store';
|
||||
import type { RuntimeConfig } from '@/core/types';
|
||||
@@ -20,6 +21,11 @@ export async function POST(request:Request){
|
||||
requireAleOperator(request,config);
|
||||
const text=await request.text();if(text.length>16000)return Response.json({error:'Request too large'},{status:413});
|
||||
const body=schema.parse(JSON.parse(text));
|
||||
if(config.GAME_BACKEND==='cmangos-classic'){
|
||||
if(body.kind!=='command')throw new Error('Classic supports native GM commands here; Lua/ALE methods belong to the Wrath backend.');
|
||||
const result=await runGM(body.command,true,true,config);
|
||||
return Response.json({ok:true,result});
|
||||
}
|
||||
const source=compileAleOperatorRequest(body,rawAleEnabled(config));
|
||||
const queued=await queueAleCommand(env.DB!,source);
|
||||
return Response.json({ok:true,command:queued},{status:202});
|
||||
|
||||
@@ -26,6 +26,7 @@ const STATEFUL_EVENTS=new Set([
|
||||
'player.close-call',
|
||||
'player.killed-by-creature',
|
||||
'player.permanent-death',
|
||||
'player.death',
|
||||
'player.repop',
|
||||
'player.resurrect'
|
||||
]);
|
||||
@@ -36,6 +37,11 @@ export async function POST(request:Request){
|
||||
requireAleBridge(request,config);
|
||||
const text=await request.text();if(text.length>40000)return Response.json({error:'Event too large'},{status:413});
|
||||
const body=schema.parse(JSON.parse(text));
|
||||
const eventId=typeof body.payload.nativeEventId==='string'?body.payload.nativeEventId.slice(0,100):null;
|
||||
if(eventId){
|
||||
await env.DB!.prepare('CREATE TABLE IF NOT EXISTS native_event_receipts (id TEXT PRIMARY KEY,at INTEGER)').run();
|
||||
if(await env.DB!.prepare('SELECT id FROM native_event_receipts WHERE id=?').bind(eventId).first())return Response.json({ok:true,duplicate:true});
|
||||
}
|
||||
await recordAleEvent(env.DB!,body.kind,body.payload);
|
||||
|
||||
if((body.kind==='player.chat'||body.kind==='player.whisper')&&body.payload.isBot!==true){
|
||||
@@ -54,6 +60,10 @@ export async function POST(request:Request){
|
||||
if(zone)await execute(store,{type:'worldTrigger',kind:'zone-change',zone,playerName:String(body.payload.name??'').slice(0,40),detail:'Authoritative ALE human-player zone event'},config);
|
||||
}
|
||||
|
||||
if(eventId){
|
||||
await env.DB!.prepare('INSERT OR IGNORE INTO native_event_receipts VALUES (?,?)').bind(eventId,Math.floor(Date.now()/1000)).run();
|
||||
await env.DB!.prepare('DELETE FROM native_event_receipts WHERE at<?').bind(Math.floor(Date.now()/1000)-8*86400).run();
|
||||
}
|
||||
return Response.json({ok:true});
|
||||
}catch(e){
|
||||
const message=e instanceof Error?e.message:'ALE event failure';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {classicMode} from '@/core/classic';
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { requireAleBridge } from '@/core/ale-auth';
|
||||
import { pollAleCommand } from '@/core/ale-store';
|
||||
@@ -9,8 +10,8 @@ async function handlePoll(request:Request){
|
||||
try{
|
||||
requireAleBridge(request,config);
|
||||
const command=await pollAleCommand(env.DB!);
|
||||
if(!command)return new Response('return',{status:200,headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store'}});
|
||||
return new Response(wrapAleCommand(command.id,command.source),{status:200,headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store','X-AzerothAI-Command':command.id}});
|
||||
if(!command)return new Response(classicMode(config)?'null':'return',{status:200,headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store'}});
|
||||
return new Response(classicMode(config)?command.source:wrapAleCommand(command.id,command.source),{status:200,headers:{'Content-Type':'text/plain; charset=utf-8','Cache-Control':'no-store','X-AzerothAI-Command':command.id}});
|
||||
}catch(e){
|
||||
const message=e instanceof Error?e.message:'ALE bridge failure';
|
||||
return Response.json({error:message},{status:message.startsWith('Unauthorized')?401:500});
|
||||
|
||||
@@ -15,7 +15,8 @@ export async function GET(){
|
||||
ok:true,
|
||||
aleActiveRecently,
|
||||
lastAleAt,
|
||||
bridgeConfigured:!!(c.BRIDGE_URL&&c.BRIDGE_TOKEN),
|
||||
gameBackend:c.GAME_BACKEND??'azerothcore',
|
||||
bridgeConfigured:c.GAME_BACKEND==='cmangos-classic'?!!c.ALE_BRIDGE_TOKEN:!!(c.BRIDGE_URL&&c.BRIDGE_TOKEN),
|
||||
aleConfigured:!!c.ALE_BRIDGE_TOKEN,
|
||||
aleOperatorConfigured:!!c.ALE_OPERATOR_TOKEN,
|
||||
aleRawEnabled:rawAleEnabled(c),
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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'}));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import {classicCommand} from '../classic';
|
||||
import {queueAleCommand} from '../ale-store';
|
||||
import {runGM} from '../gm';
|
||||
import type { GameConnector,Bot,Decision,RuntimeConfig } from '../types';
|
||||
|
||||
const deathSignal=z.object({
|
||||
@@ -45,5 +48,21 @@ export class AzerothCoreConnector implements GameConnector {
|
||||
async decide(bot:Bot,decision:Decision,requestId:string){return z.object({accepted:z.literal(true),summary:z.string().max(1000),zone:z.string().max(120).optional()}).parse(await this.request(`/bots/${encodeURIComponent(bot.id)}/intent`,{decision,requestId}));}
|
||||
}
|
||||
|
||||
export class ClassicConnector implements GameConnector {
|
||||
id='cmangos-classic';
|
||||
constructor(private config:RuntimeConfig){}
|
||||
async health(){const result=await runGM('server info',true,true,this.config);return {ok:true,detail:result.output};}
|
||||
async observe(bot:Bot){
|
||||
if(!bot.world||Date.now()/1000-bot.world.at>30)throw new Error('Fresh Classic observation required');
|
||||
return {zone:bot.zone,level:bot.level,alive:!bot.world.isDead&&!bot.world.isGhost,availableActions:bot.personality.activities,summary:JSON.stringify({zone:bot.zone,level:bot.level,health:bot.world.healthPct,mana:bot.world.manaPct,quests:bot.world.quests,combat:bot.world.inCombat,party:bot.world.groupNames})};
|
||||
}
|
||||
async lifecycle(){throw new Error('Classic maintains the fixed 50-character roster automatically. Manual lifecycle changes are not enabled.');}
|
||||
async decide(bot:Bot,decision:Decision){
|
||||
if(!this.config.DB)throw new Error('Classic command database is unavailable');
|
||||
const queued=await queueAleCommand(this.config.DB,classicCommand('intent',bot,decision.action));
|
||||
return {summary:`${bot.name}: requested ${decision.action}; native receipt and observed outcome are pending (${queued.id}).`,zone:bot.zone};
|
||||
}
|
||||
}
|
||||
|
||||
// Add a connector factory here; personality and memory code remains game-independent.
|
||||
export const connectors:Record<string,(config:RuntimeConfig)=>GameConnector>={simulation:()=>new SimulationConnector(),azerothcore:c=>new AzerothCoreConnector(c)};
|
||||
export const connectors:Record<string,(config:RuntimeConfig)=>GameConnector>={simulation:()=>new SimulationConnector(),azerothcore:c=>c.GAME_BACKEND==='cmangos-classic'?new ClassicConnector(c):new AzerothCoreConnector(c)};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export type ConversationChannel='say'|'whisper';
|
||||
|
||||
export type ConversationRequest={
|
||||
speakerGuid?:number;
|
||||
speakerAccountId?:number;
|
||||
socialTurn?:0|1|2|3;
|
||||
id:string;
|
||||
createdAt:string;
|
||||
@@ -16,6 +18,7 @@ export type ConversationRequest={
|
||||
};
|
||||
|
||||
async function ensure(db:D1Database){
|
||||
await db.prepare('CREATE TABLE IF NOT EXISTS conversation_identity (id TEXT PRIMARY KEY,guid INTEGER NOT NULL,account_id INTEGER NOT NULL)').run();
|
||||
await db.prepare(`CREATE TABLE IF NOT EXISTS conversation_requests (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
@@ -68,6 +71,9 @@ export async function queueConversationEvent(db:D1Database,kind:string,payload:R
|
||||
text(payload,'selectedPlayer',40),
|
||||
text(payload,'nearbyBots',1000)
|
||||
).run();
|
||||
if(Number.isInteger(payload.characterGuid)&&Number(payload.characterGuid)>0&&Number.isInteger(payload.accountId)&&Number(payload.accountId)>0){
|
||||
await db.prepare('INSERT INTO conversation_identity VALUES (?,?,?)').bind(id,payload.characterGuid,payload.accountId).run();
|
||||
}
|
||||
return {id,createdAt};
|
||||
}
|
||||
|
||||
@@ -82,6 +88,7 @@ export async function leaseConversation(db:D1Database):Promise<ConversationReque
|
||||
const row=await db.prepare(`SELECT id,created_at,speaker_name,message,channel,zone_name,receiver,selected_player,nearby_bots,status,attempts
|
||||
FROM conversation_requests WHERE status='pending' ORDER BY CASE WHEN channel LIKE 'social-%' THEN 1 ELSE 0 END,created_at LIMIT 1`).first<any>();
|
||||
if(!row)return null;
|
||||
const identity=await db.prepare('SELECT guid,account_id FROM conversation_identity WHERE id=?').bind(row.id).first<{guid:number;account_id:number}>();
|
||||
|
||||
const startedAt=new Date().toISOString();
|
||||
const updated=await db.prepare(`UPDATE conversation_requests
|
||||
@@ -90,6 +97,7 @@ export async function leaseConversation(db:D1Database):Promise<ConversationReque
|
||||
if(updated.meta.changes!==1)return null;
|
||||
|
||||
return {
|
||||
speakerGuid:identity?.guid,speakerAccountId:identity?.account_id,
|
||||
socialTurn:row.channel==='social-start'?0:row.channel==='social-reply'?1:row.channel==='social-turn-2'?2:row.channel==='social-turn-3'?3:undefined,
|
||||
id:row.id,
|
||||
createdAt:row.created_at,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {classicMode,classicCommand,classicPlayerReply} from './classic';
|
||||
import {bondEvidence,socialEntry,communityTick} from './community';
|
||||
import { z } from 'zod';
|
||||
import { queueAleCommand } from './ale-store';
|
||||
@@ -180,7 +181,7 @@ export async function runConversationTick(store:StateStore,db:D1Database,config:
|
||||
}
|
||||
}
|
||||
await cleanupConversations(db);
|
||||
await communityTick(store,db);
|
||||
await communityTick(store,db,config);
|
||||
const request=await leaseConversation(db);
|
||||
if(!request)return {ok:true,ran:false,reason:'No player conversation waiting'};
|
||||
|
||||
@@ -202,7 +203,8 @@ export async function runConversationTick(store:StateStore,db:D1Database,config:
|
||||
reply=cleanReply(reply);
|
||||
if(!reply)throw new Error('Conversation reply was empty');
|
||||
|
||||
const command=await queueAleCommand(db,compileReply(bot.name,request.speakerName,reply,request.channel,request.socialTurn!==undefined));
|
||||
const source=classicMode(config)?(request.socialTurn===undefined?classicPlayerReply(bot,request,reply):classicCommand('say',bot,reply,byName(state,request.speakerName))):compileReply(bot.name,request.speakerName,reply,request.channel,request.socialTurn!==undefined);
|
||||
const command=await queueAleCommand(db,source);
|
||||
await awaitDelivery(db,request,bot.id,reply,command.id);
|
||||
await cleanupConversations(db);
|
||||
|
||||
|
||||
+3
-2
@@ -7,11 +7,12 @@ export function parseGM(raw:string){
|
||||
}
|
||||
const escapeXML=(s:string)=>s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
export async function runGM(raw:string,live:boolean,confirmed:boolean,config:RuntimeConfig){
|
||||
const command=parseGM(raw);
|
||||
const parsed=parseGM(raw);
|
||||
const command=config.GAME_BACKEND==='cmangos-classic'?parsed.replace(/^teleport name /,'tele name '):parsed;
|
||||
if(!live)return {command,output:`[SIMULATED] Accepted: ${command}\nNo command was sent to a game server.`};
|
||||
if(!confirmed)throw new Error('Confirm the live GM command before execution');
|
||||
if(!config.SOAP_URL||!config.SOAP_USER||!config.SOAP_PASSWORD)throw new Error('Configure SOAP_URL, SOAP_USER and SOAP_PASSWORD on the server first');
|
||||
const r=await fetch(config.SOAP_URL,{method:'POST',headers:{'Content-Type':'text/xml; charset=utf-8',Authorization:`Basic ${btoa(`${config.SOAP_USER}:${config.SOAP_PASSWORD}`)}`},body:`<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="urn:AC"><SOAP-ENV:Body><ns1:executeCommand><command>${escapeXML(command)}</command></ns1:executeCommand></SOAP-ENV:Body></SOAP-ENV:Envelope>`,signal:AbortSignal.timeout(12000),redirect:'error'});
|
||||
const r=await fetch(config.SOAP_URL,{method:'POST',headers:{'Content-Type':'text/xml; charset=utf-8',Authorization:`Basic ${btoa(`${config.SOAP_USER}:${config.SOAP_PASSWORD}`)}`},body:`<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="${config.GAME_BACKEND==='cmangos-classic'?'urn:MaNGOS':'urn:AC'}"><SOAP-ENV:Body><ns1:executeCommand><command>${escapeXML(command)}</command></ns1:executeCommand></SOAP-ENV:Body></SOAP-ENV:Envelope>`,signal:AbortSignal.timeout(12000),redirect:'manual'});
|
||||
const xml=await r.text();if(!r.ok||/<(?:\w+:)?Fault\b/.test(xml))throw new Error(`SOAP command failed (HTTP ${r.status}). Check the worldserver log.`);
|
||||
const output=xml.match(/<(?:\w+:)?result(?:\s[^>]*)?>([\s\S]*?)<\/(?:\w+:)?result>/)?.[1];
|
||||
if(output===undefined)throw new Error('Unexpected SOAP response; check the worldserver before retrying');
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
+6
-3
@@ -210,7 +210,7 @@ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,pa
|
||||
b.memories=b.memories.slice(-100);
|
||||
}
|
||||
event(s,'memory',`${b.name} survived a close call in ${b.zone}; the experience was committed to memory.`,b.id);
|
||||
}else if(kind==='player.killed-by-creature'||kind==='player.permanent-death'){
|
||||
}else if(kind==='player.killed-by-creature'||kind==='player.permanent-death'||kind==='player.death'){
|
||||
if(b.plotArmorHpFloor>=1){
|
||||
event(s,'error',`Plot armor breach: authoritative ALE reported ${b.name} dead. Worldserver damage enforcement must prevent this transition.`,b.id);
|
||||
}else if(s.settings.hardcore.enabled){
|
||||
@@ -236,7 +236,10 @@ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,pa
|
||||
export async function execute(store:StateStore,input:unknown,config:RuntimeConfig={}) {
|
||||
const a=actionSchema.parse(input);let s=await store.load();
|
||||
const connector=connectors[s.settings.connector](config);
|
||||
if(a.type==='health')return {health:await connector.health()};
|
||||
if(a.type==='health'){
|
||||
if(config.GAME_BACKEND==='cmangos-classic'){const result=await runGM('server info',true,true,config);return {health:{ok:true,detail:result.output}};}
|
||||
return {health:await connector.health()};
|
||||
}
|
||||
const active=Object.values(s.operations).find(o=>Date.now()-o.started<60000);
|
||||
if(active)throw new Error('A realm operation is in progress. Wait for it to finish.');
|
||||
if(Object.keys(s.operations).length){s.operations={};event(s,'error','An interrupted operation expired. Verify live server state before retrying.');}
|
||||
@@ -293,7 +296,7 @@ export async function execute(store:StateStore,input:unknown,config:RuntimeConfi
|
||||
s.recipes=old?s.recipes.map(r=>r.id===old.id?recipe:r):[...s.recipes,recipe];event(s,'recipe',`${recipe.name} saved as version ${recipe.version}. Existing bots retain their snapshot.`);
|
||||
}else if(a.type==='settings'){
|
||||
if(a.connector!==s.settings.connector&&s.bots.some(b=>b.status==='online'||b.status==='paused'))throw new Error('Despawn all online and paused bots before switching connectors.');
|
||||
if(a.connector==='azerothcore'&&(!config.BRIDGE_URL||!config.BRIDGE_TOKEN))throw new Error('AzerothCore bridge is not configured on the server.');
|
||||
if(a.connector==='azerothcore'&&config.GAME_BACKEND!=='cmangos-classic'&&(!config.BRIDGE_URL||!config.BRIDGE_TOKEN))throw new Error('AzerothCore bridge is not configured on the server.');
|
||||
if(a.provider!=='deterministic'&&!config.LLM_URL)throw new Error('Local LLM endpoint is not configured on the server.');
|
||||
s.settings={connector:a.connector,provider:a.provider,model:a.model,brains:a.brains,hardcore:a.hardcore,population:a.population};
|
||||
event(s,'system',`Realm connector and model settings updated. Hardcore one-life mode is ${a.hardcore.enabled?'enabled':'disabled'}.`);
|
||||
|
||||
+4
-3
@@ -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};
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Classic migration staging
|
||||
|
||||
Target: CMaNGOS Classic, client 1.12.1 build 5875, with CMaNGOS Playerbots.
|
||||
This directory contains the staged adapter and migration helpers. A successful
|
||||
build is not a completed migration or permission to bypass gameplay validation.
|
||||
|
||||
`stage-data.py --root /path/to/isolated/migration` observes an existing
|
||||
`azerothai-classic-build` container, verifies the transferred MPQs against
|
||||
`client-manifest.json`, and runs the upstream extractor with two worker threads.
|
||||
The manifest maps relative MPQ paths to `size` and `sha256`. Checksums verify
|
||||
transfer integrity against the supplied client manifest, not publisher authenticity.
|
||||
The Docker builder image must be named `azerothai-classic-builder:local`; the
|
||||
installed extractor must be under `run/bin/tools`. The staging directory and
|
||||
container files belong to UID/GID 1000. Containers have a two-core/four-GiB limit.
|
||||
|
||||
Progress and failures are written to `prerequisites-status.json`. A successful
|
||||
extraction is only a prerequisite: database import, the Classic control adapter,
|
||||
the 50-character roster, account protection, and gameplay validation remain.
|
||||
The script never starts the new realm, changes the old realm, or deletes bots.
|
||||
|
||||
`install-native.py SOURCE` installs the native adapter and version-checked hooks.
|
||||
The adapter provides identity-bound snapshots, typed intentions, guarded native
|
||||
party/guild invitations, bot-to-bot speech, durable command receipts, and Zaku's
|
||||
damage floor. `AzerothAI.RestrictBotLogin=1` restricts native bot logins to an
|
||||
explicit roster of at most 50 identities. `AzerothAI.Enable=1` enables observations
|
||||
and managed login maintenance. Both settings default off in upstream builds.
|
||||
|
||||
`plan-roster.py ACTIVE_JSON OUTPUT_JSON` maps exactly 50 saved identities to
|
||||
valid Vanilla combinations without changing faction. Death Knights become
|
||||
Warriors, Alliance Shamans become Paladins, and Horde Paladins become Shamans.
|
||||
Names remain stable; all characters are created at level 1 using native creation.
|
||||
|
||||
`bootstrap.py --root ROOT` waits for successful extraction and native compilation,
|
||||
configures the isolated Classic world/database, starts a localhost-only game
|
||||
port, creates the planned characters through the native console, and validates
|
||||
all 50 identities and levels. It leaves those characters **offline**, writes
|
||||
`classic-roster-bound.json`, and records progress in `bootstrap-status.json`.
|
||||
It does not start authentication, change client configuration, or stop Wrath.
|
||||
|
||||
`prepare-state.py --root ROOT --live LIVE_REPO` prepares a separate dashboard
|
||||
database from a consistent SQLite backup. It preserves active personalities and
|
||||
labels older memories as historical, rebinds native IDs, and resets current-world
|
||||
observations, command queues, and the population plan. It refuses to overwrite
|
||||
an existing staging database. Run before starting the staging web runtime.
|
||||
|
||||
The staging app requires `GAME_BACKEND=cmangos-classic`. `relay.py --root ROOT
|
||||
--url http://127.0.0.1:8791 --env-file ROOT/app-staging/.dev.vars` transfers only
|
||||
validated typed commands into the Classic character database. It never executes
|
||||
Lua or arbitrary game commands. Native receipts distinguish accepted intentions
|
||||
from observed gameplay outcomes; interrupted commands are not replayed.
|
||||
|
||||
`install-staging-services.py --root ROOT` installs isolated user services for
|
||||
port 8791, the native relay, and autonomy. Stop manually launched staging
|
||||
processes first. Services restart on failure and leave the live Wrath services
|
||||
unchanged. The worker credential file is private to the owner.
|
||||
|
||||
The rebuildable item-info cache uses InnoDB with a dynamic row format so the
|
||||
upstream builder's transaction batches writes with full database durability.
|
||||
The bootstrap records the existing native bot account range for login
|
||||
registration and enables activity even when no human player is present.
|
||||
Automatic population creation remains disabled; the native identity allowlist
|
||||
still limits managed login to the planned 50.
|
||||
|
||||
Before cutover, verify fresh 50-character observations, quest and spell outcomes,
|
||||
normal resurrection, actual party receipts, native GM commands, authentication,
|
||||
and rollback. Human player replies require an actual in-game session for an
|
||||
end-to-end acceptance check. Lua/ALE reflection remains a Wrath-only capability.
|
||||
|
||||
Migration safeguards:
|
||||
|
||||
- Preserve the verified Wrath backup and the active-50 identity export.
|
||||
- Maintain 25 Alliance and 25 Horde. Use valid Vanilla race/class combinations.
|
||||
- Start new Vanilla characters at level 1; do not directly import Wrath inventory,
|
||||
talent, spell, or quest tables. Blood Elves, Draenei, Death Knights, Alliance
|
||||
Shamans, and Horde Paladins need explicit compatible replacements.
|
||||
- Retain personalities and historical memories; reset live observations, GUIDs,
|
||||
action queues, and progress baselines when binding the new characters.
|
||||
- Protect human/GM accounts. Inactive managed profiles are cleanup candidates,
|
||||
not proof that corresponding game characters can safely be deleted.
|
||||
- Test native bot login, progress, resurrection, safety rules, read-only visitor
|
||||
access, and current observations before switching the live dashboard or client.
|
||||
|
||||
Native outcome hooks now report quest completion/rewards, loot item counts,
|
||||
looted copper, deaths, resurrection and learned spells. These are separate from
|
||||
accepted intentions. The configured native auto-learn policy is invoked on
|
||||
managed level changes; this is not a claim of trainer purchases. Player say and
|
||||
whisper events bind the sender's native character/account identity and replies
|
||||
revalidate that identity. Basic GM commands use Classic's own SOAP namespace;
|
||||
Wrath-only Lua operator calls are rejected explicitly.
|
||||
|
||||
`prepare-auth.py --root ROOT` prepares a private Classic service GM account,
|
||||
copies only ENDAMI's existing SRP credentials into the isolated authentication
|
||||
database, and disables upstream sample logins. `check-auth.py --root ROOT`
|
||||
verifies a complete SRP handshake and realm listing without entering the game.
|
||||
`health.py --root ROOT --url URL` reports host health without Wrath SQL.
|
||||
|
||||
`cutover.py --root ROOT` requires a passing build and all 50 fresh staging bots,
|
||||
backs up stopped Wrath accounts/characters, switches the existing endpoints, and
|
||||
automatically restores Wrath endpoints if login or population verification fails.
|
||||
Miniflare metadata databases are excluded from game-state backup selection.
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded daily backup of Classic persistent accounts, characters and dashboard."""
|
||||
import argparse,gzip,json,os,pathlib,shutil,sqlite3,subprocess,time
|
||||
p=argparse.ArgumentParser();p.add_argument('--root',type=pathlib.Path,required=True);a=p.parse_args();root=a.root
|
||||
base=root/'backups';base.mkdir(mode=0o700,exist_ok=True)
|
||||
target=base/time.strftime('%Y%m%dT%H%M%SZ',time.gmtime());target.mkdir(mode=0o700)
|
||||
c=json.loads((root/'database-credentials.json').read_text())
|
||||
try:
|
||||
with gzip.open(target/'classic-auth-characters.sql.gz','wb') as out:
|
||||
p=subprocess.Popen(['docker','exec','-e','MYSQL_PWD','azerothai-classic-db','mysqldump','-uroot','--single-transaction','--set-gtid-purged=OFF','--databases','classicrealmd','classiccharacters'],stdout=subprocess.PIPE,stderr=subprocess.PIPE,env=dict(os.environ,MYSQL_PWD=c['root']))
|
||||
shutil.copyfileobj(p.stdout,out)
|
||||
if p.wait():raise RuntimeError('Classic backup failed')
|
||||
dbs=[p for p in (root/'app-staging/.wrangler/state/v3/d1').rglob('*.sqlite') if p.name!='metadata.sqlite'];assert len(dbs)==1
|
||||
source=sqlite3.connect(dbs[0]);dest=sqlite3.connect(target/'dashboard.sqlite');source.backup(dest);dest.close();source.close()
|
||||
shutil.copytree(root/'run/etc',target/'config')
|
||||
(target/'complete.json').write_text(json.dumps({'at':time.time(),'backend':'cmangos-classic'}))
|
||||
for old in sorted(base.iterdir(),reverse=True)[3:]:
|
||||
if old.is_dir() and (old/'complete.json').exists():shutil.rmtree(old)
|
||||
print('Classic backup complete: '+str(target))
|
||||
except Exception:
|
||||
(target/'failed').touch();raise
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resume isolated Classic startup and native level-one roster creation.
|
||||
|
||||
Never switches ports, touches Wrath databases, or starts bot login. Native
|
||||
characters remain offline until the connector and gameplay checks are ready.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
def main(root):
|
||||
credentials=json.loads((root/'database-credentials.json').read_text())
|
||||
env=dict(os.environ,MYSQL_PWD=credentials['root'])
|
||||
def mysql(sql):
|
||||
result=subprocess.run(['docker','exec','-i','-e','MYSQL_PWD','azerothai-classic-db','mysql','-uroot','-N','-B','--raw','classiccharacters'],input=sql,text=True,capture_output=True,env=env)
|
||||
if result.returncode: raise RuntimeError('Isolated Classic database operation failed: '+result.stderr.replace(credentials['root'],'[redacted]').replace(credentials['password'],'[redacted]'))
|
||||
return result.stdout.strip()
|
||||
def status(stage,**extra):
|
||||
value={'at':time.time(),'stage':stage,'cutover':False,**extra}
|
||||
temp=root/'bootstrap-status.tmp';temp.write_text(json.dumps(value,indent=2));temp.replace(root/'bootstrap-status.json')
|
||||
print(stage,flush=True)
|
||||
def inspect(name):
|
||||
result=subprocess.run(['docker','inspect','--format','{{json .State}}',name],capture_output=True,text=True)
|
||||
return json.loads(result.stdout) if result.returncode==0 else None
|
||||
def configure(name,values):
|
||||
template=root/'run/etc'/f'{name}.conf.dist'
|
||||
text=template.read_text()
|
||||
for key,value in values.items():
|
||||
line=key+' = '+str(value)
|
||||
pattern=r'^'+re.escape(key)+r'\s*=.*$'
|
||||
text=re.sub(pattern,lambda _:line,text,flags=re.M) if re.search(pattern,text,re.M) else text+'\n'+line+'\n'
|
||||
target=template.with_suffix('');target.write_text(text);target.chmod(0o600)
|
||||
try:
|
||||
status('waiting for verified extraction and native build')
|
||||
deadline=time.time()+6*3600
|
||||
while time.time()<deadline:
|
||||
extraction=inspect('azerothai-classic-extract');build=inspect('azerothai-classic-native-build')
|
||||
if extraction and build and not extraction['Running'] and not build['Running']:
|
||||
if extraction['ExitCode'] or build['ExitCode']:raise RuntimeError('Extraction or native build failed; see its log')
|
||||
break
|
||||
time.sleep(15)
|
||||
else:raise RuntimeError('Prerequisite wait exceeded six hours')
|
||||
# Require the verifier, not merely a zero process exit.
|
||||
for _ in range(12):
|
||||
stage=json.loads((root/'prerequisites-status.json').read_text())['stage']
|
||||
if stage.startswith('prerequisites extracted;'):break
|
||||
time.sleep(5)
|
||||
else:raise RuntimeError('Extraction verifier has not accepted generated data')
|
||||
for directory in ['dbc','maps','vmaps','mmaps']:
|
||||
if not any((root/'extracted'/directory).glob('*')):raise RuntimeError('Missing '+directory)
|
||||
roster=json.loads((root/'classic-roster-plan.json').read_text())
|
||||
if len(roster)!=50 or sum(b['team']==0 for b in roster)!=25 or sum(b['team']==1 for b in roster)!=25:raise RuntimeError('Invalid roster plan')
|
||||
if any(not re.fullmatch('[A-Za-z]{2,12}',b['name']) or b['level']!=1 for b in roster):raise RuntimeError('Invalid native creation parameters')
|
||||
existing=mysql('SELECT name FROM characters').splitlines()
|
||||
if any(name not in {b['name'] for b in roster} for name in existing):raise RuntimeError('Unexpected characters in isolated database; refusing to modify')
|
||||
mysql((root/'adapter/schema.sql').read_text())
|
||||
engine=mysql("SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA='classiccharacters' AND TABLE_NAME='ai_playerbot_item_info_cache'")
|
||||
if engine!='InnoDB':mysql('ALTER TABLE ai_playerbot_item_info_cache ENGINE=InnoDB, ROW_FORMAT=DYNAMIC;')
|
||||
(root/'run/logs').mkdir(exist_ok=True)
|
||||
values={'DataDir':'"/work/extracted"','LogsDir':'"/work/run/logs"','WorldServerPort':8086,'Console.Enable':1,'Ra.Enable':0,'SOAP.Enabled':0,'AzerothAI.Enable':0,'AzerothAI.RestrictBotLogin':1}
|
||||
for key,db in [('Login','classicrealmd'),('World','classicmangos'),('Character','classiccharacters'),('Logs','classiclogs')]:values[key+'DatabaseInfo']='"azerothai-classic-db;3306;'+credentials['user']+';'+credentials['password']+';'+db+'"'
|
||||
configure('mangosd',values)
|
||||
configure('aiplayerbot',{'AiPlayerbot.Enabled':1,'AiPlayerbot.DisableBotOptimizations':1,'AiPlayerbot.DisableActivityPriorities':1,'AiPlayerbot.botActiveAlone':100,'AiPlayerbot.RandomBotAutologin':0,'AiPlayerbot.RandomBotLoginAtStartup':0,'AiPlayerbot.BotAutologin':0,'AiPlayerbot.RandomBotAutoCreate':0,'AiPlayerbot.MinRandomBots':50,'AiPlayerbot.MaxRandomBots':50,'AiPlayerbot.RandomBotAccountCount':0,'AiPlayerbot.RandomBotAccountPrefix':'AZCLASSIC','AiPlayerbot.RandomBotGuildCount':0,'AiPlayerbot.RandomBotArenaTeamCount':0,'AiPlayerbot.DisableRandomLevels':1,'AiPlayerbot.RandomGearUpgradeEnabled':0,'AiPlayerbot.AutoLearnTrainerSpells':1,'AiPlayerbot.AutoTrainSpells':'yes','AiPlayerbot.RandomBotJoinBG':0,'AiPlayerbot.RandomBotJoinLfg':0,'AiPlayerbot.RandomBotMaps':'0,1'})
|
||||
fifo=root/'run/console'
|
||||
if not fifo.exists():os.mkfifo(fifo,0o600)
|
||||
state=inspect('azerothai-classic-world')
|
||||
if state is None:
|
||||
subprocess.run(['docker','run','-d','--name','azerothai-classic-world','--network','azerothai-classic','--cpus','2','--memory','3g','--user','1000:1000','-v',str(root)+':/work','-w','/work/run/bin','-p','127.0.0.1:8086:8086','azerothai-classic-builder:local','bash','-c','exec 3<>/work/run/console; exec ./mangosd -c /work/run/etc/mangosd.conf <&3'],check=True,capture_output=True)
|
||||
elif not state['Running']:subprocess.run(['docker','start','azerothai-classic-world'],check=True,capture_output=True)
|
||||
status('starting isolated Classic world; bot login disabled')
|
||||
for attempt in range(720):
|
||||
state=inspect('azerothai-classic-world')
|
||||
if not state or not state['Running']:raise RuntimeError('Classic world exited during startup; inspect container log')
|
||||
log=subprocess.run(['docker','logs','--since',state['StartedAt'],'azerothai-classic-world'],capture_output=True,text=True).stdout
|
||||
if 'SERVER STARTUP TIME' in log or 'World initialized' in log:break
|
||||
if attempt%12==0:status('Classic first startup is building caches; bot login disabled',waitedSeconds=attempt*5)
|
||||
time.sleep(5)
|
||||
else:raise RuntimeError('Classic startup not confirmed within sixty minutes; inspect cache progress')
|
||||
# Restore full durability before any persistent characters are created.
|
||||
mysql('SET GLOBAL innodb_flush_log_at_trx_commit=1; SET GLOBAL sync_binlog=1;')
|
||||
status('creating 50 level-one native characters; bot login disabled')
|
||||
with fifo.open('w') as console:
|
||||
for bot in roster:
|
||||
name=bot['name']
|
||||
if not mysql("SELECT guid FROM characters WHERE name='"+name+"'"):
|
||||
console.write(f"rndbot create name={name} race={bot['raceId']} class={bot['classId']} level=1 login=false gear=none\n");console.flush()
|
||||
for _ in range(20):
|
||||
if mysql("SELECT guid FROM characters WHERE name='"+name+"'"):break
|
||||
time.sleep(1)
|
||||
else:raise RuntimeError('Native creation not confirmed for '+name)
|
||||
bound=[]
|
||||
for bot in roster:
|
||||
row=mysql("SELECT guid,account,race,class,level,online FROM characters WHERE name='"+bot['name']+"'").split('\t')
|
||||
if len(row)!=6:raise RuntimeError('Missing character identity')
|
||||
guid,account,race,cls,level,online=map(int,row)
|
||||
if (race,cls,level,online)!=(bot['raceId'],bot['classId'],1,0):raise RuntimeError('Native identity or level validation failed: '+bot['name'])
|
||||
mysql(f"INSERT INTO azerothai_roster(guid,account_id,name,enabled,plot_armor) VALUES ({guid},{account},'{bot['name']}',0,{int(bot['name']=='Zaku')}) ON DUPLICATE KEY UPDATE account_id=VALUES(account_id),name=VALUES(name)")
|
||||
bound.append(dict(bot,characterGuid=guid,accountId=account))
|
||||
if int(mysql('SELECT COUNT(*) FROM characters'))!=50:raise RuntimeError('Population differs from 50')
|
||||
(root/'classic-roster-bound.json').write_text(json.dumps(bound,indent=2)+'\n')
|
||||
# Native login authorization reloads this account range on restart.
|
||||
account_limit=int(mysql("SELECT COALESCE(MAX(CAST(SUBSTRING(username,10) AS UNSIGNED))+1,0) FROM classicrealmd.account WHERE username REGEXP '^AZCLASSIC[0-9]+$'"))
|
||||
config_path=root/'run/etc/aiplayerbot.conf'
|
||||
config_path.write_text(re.sub(r'^AiPlayerbot.RandomBotAccountCount\s*=.*$',f'AiPlayerbot.RandomBotAccountCount = {account_limit}',config_path.read_text(),flags=re.M))
|
||||
status('Classic world ready; 50 level-one characters verified offline; connector and gameplay validation remain',characters=50,alliance=25,horde=25)
|
||||
except Exception as error:
|
||||
status('bootstrap blocked',error=str(error).replace(credentials['root'],'[redacted]').replace(credentials['password'],'[redacted]'))
|
||||
raise
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser();parser.add_argument('--root',type=pathlib.Path,required=True)
|
||||
main(parser.parse_args().root.resolve())
|
||||
@@ -0,0 +1,5 @@
|
||||
-- The upstream item-info builder already batches its writes in a transaction.
|
||||
-- MyISAM ignores that transaction and syncs each statement to the binary log.
|
||||
-- Use InnoDB so every restart retains full durability without per-row fsync.
|
||||
-- Apply to the isolated Classic character database before starting mangosd.
|
||||
ALTER TABLE ai_playerbot_item_info_cache ENGINE=InnoDB, ROW_FORMAT=DYNAMIC;
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify Classic SRP login and realm listing without entering the game world."""
|
||||
import argparse,hashlib,json,pathlib,secrets,socket,struct
|
||||
|
||||
def verify(host,port,credentials):
|
||||
username=credentials['username'].upper().encode();password=credentials['password'].upper().encode()
|
||||
def h(data):return hashlib.sha1(data).digest()
|
||||
with socket.create_connection((host,port),timeout=8) as sock:
|
||||
def read(n):
|
||||
data=b''
|
||||
while len(data)<n:
|
||||
block=sock.recv(n-len(data))
|
||||
if not block:raise RuntimeError('Authentication connection closed')
|
||||
data+=block
|
||||
return data
|
||||
payload=b'WoW\0'+bytes([1,12,1])+struct.pack('<H',5875)+b'68x\0'+b'niW\0'+b'SUne'+struct.pack('<I',0)+socket.inet_aton('127.0.0.1')+bytes([len(username)])+username
|
||||
sock.sendall(bytes([0,0])+struct.pack('<H',len(payload))+payload)
|
||||
header=read(3)
|
||||
if header!=b'\0\0\0':raise RuntimeError('Classic challenge rejected: '+header.hex())
|
||||
Braw=read(32);graw=read(read(1)[0]);Nraw=read(read(1)[0]);salt=read(32);read(16)
|
||||
flags=read(1)[0]
|
||||
if flags:raise RuntimeError('Unexpected extra authentication factor')
|
||||
N=int.from_bytes(Nraw,'little');g=int.from_bytes(graw,'little');B=int.from_bytes(Braw,'little')
|
||||
a=secrets.randbits(256);A=pow(g,a,N).to_bytes(32,'little')
|
||||
x=int.from_bytes(h(salt+h(username+b':'+password)),'little')
|
||||
u=int.from_bytes(h(A+Braw),'little')
|
||||
secret=pow((B-3*pow(g,x,N))%N,a+u*x,N).to_bytes(32,'little')
|
||||
even=h(secret[::2]);odd=h(secret[1::2]);key=bytes(v for pair in zip(even,odd) for v in pair)
|
||||
proof=h(bytes(x^y for x,y in zip(h(Nraw),h(graw)))+h(username)+salt+A+Braw+key)
|
||||
sock.sendall(b'\x01'+A+proof+bytes(20)+b'\0\0')
|
||||
result=read(2)
|
||||
if result!=b'\x01\0':raise RuntimeError('Classic password proof rejected: '+result.hex())
|
||||
if read(20)!=h(A+proof+key):raise RuntimeError('Server password proof did not match')
|
||||
read(4)
|
||||
sock.sendall(b'\x10'+bytes(4))
|
||||
if read(1)!=b'\x10':raise RuntimeError('Unexpected realm-list response')
|
||||
realms=read(struct.unpack('<H',read(2))[0])
|
||||
if b'AzerothAI Classic\0' not in realms:raise RuntimeError('Expected Classic realm missing')
|
||||
print('Classic password proof, server proof and AzerothAI Classic realm listing verified.')
|
||||
|
||||
if __name__=='__main__':
|
||||
p=argparse.ArgumentParser();p.add_argument('--root',type=pathlib.Path,required=True);p.add_argument('--host',default='127.0.0.1');p.add_argument('--port',type=int,default=3725);a=p.parse_args()
|
||||
verify(a.host,a.port,json.loads((a.root/'classic-gm-credentials.json').read_text()))
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Switch the validated Classic staging realm into the existing local endpoints.
|
||||
Preserves Wrath containers, user services, database and a final database dump.
|
||||
On an error restores the former endpoints without overwriting either database.
|
||||
"""
|
||||
import argparse,gzip,json,os,pathlib,shutil,sqlite3,subprocess,time,urllib.request
|
||||
|
||||
|
||||
def main(root):
|
||||
home=pathlib.Path.home();units=home/'.config/systemd/user';app=root/'app-staging'
|
||||
archive=root/('cutover-backup-'+str(int(time.time())));archive.mkdir(mode=0o700,exist_ok=False)
|
||||
def run(args,check=True):
|
||||
p=subprocess.run(args,capture_output=True,text=True)
|
||||
if check and p.returncode:raise RuntimeError('Command failed: '+' '.join(args[:4])+' '+p.stderr[-500:])
|
||||
return p.stdout.strip()
|
||||
def status(stage,**extra):
|
||||
value={'at':time.time(),'stage':stage,**extra};(root/'cutover-status.json').write_text(json.dumps(value,indent=2));print(stage,flush=True)
|
||||
def state():return json.load(urllib.request.urlopen('http://127.0.0.1:8791/api/state',timeout=10))
|
||||
s=state();bots=s['bots']
|
||||
assert (root/'final-build-passed').exists()
|
||||
assert len(bots)==50 and all(b['status']=='online' and time.time()-b['world']['at']<20 for b in bots)
|
||||
assert sum(b['world']['team']==0 for b in bots)==25 and sum(b['world']['team']==1 for b in bots)==25
|
||||
assert not s['settings']['hardcore']['enabled']
|
||||
(archive/'classic-state.json').write_text(json.dumps(s))
|
||||
old=['azerothai-runtime','azerothai-autonomy','azerothai-ale-relay','azerothai-operations.timer','azerothai-backup.timer']
|
||||
classic=['azerothai-classic-runtime','azerothai-classic-relay','azerothai-classic-autonomy']
|
||||
for file in units.glob('azerothai*'):shutil.copy2(file,archive/file.name)
|
||||
shutil.copy2(app/'.dev.vars',archive/'classic.dev.vars');shutil.copy2(root/'staging-worker.env',archive/'staging-worker.env')
|
||||
changed=False
|
||||
try:
|
||||
status('Stopping services for the validated Classic cutover')
|
||||
changed=True
|
||||
run(['systemctl','--user','disable','--now',*old],False)
|
||||
run(['systemctl','--user','stop','azerothai-operations.service','azerothai-backup.service','azerothai-spectator',*classic],False)
|
||||
run(['docker','stop','-t','30','ac-worldserver','ac-authserver','azerothai-dashboard-proxy'])
|
||||
run(['docker','rename','azerothai-dashboard-proxy','azerothai-dashboard-proxy-wrath-rollback'])
|
||||
status('Saving final stopped Wrath character and account databases')
|
||||
cfg=json.loads(run(['docker','inspect','ac-database']))[0]
|
||||
values=dict(v.split('=',1) for v in cfg['Config']['Env'] if '=' in v)
|
||||
password=values.get('MYSQL_ROOT_PASSWORD') or values.get('MARIADB_ROOT_PASSWORD')
|
||||
env=dict(os.environ,MYSQL_PWD=password)
|
||||
with gzip.open(archive/'wrath-auth-characters.sql.gz','wb') as out:
|
||||
proc=subprocess.Popen(['docker','exec','-e','MYSQL_PWD','ac-database','mysqldump','-uroot','--single-transaction','--set-gtid-purged=OFF','--databases','acore_auth','acore_characters'],stdout=subprocess.PIPE,stderr=subprocess.PIPE,env=env)
|
||||
shutil.copyfileobj(proc.stdout,out)
|
||||
if proc.wait():raise RuntimeError('Final Wrath database backup failed')
|
||||
assert (archive/'wrath-auth-characters.sql.gz').stat().st_size>10000
|
||||
status('Publishing Classic game and authentication ports')
|
||||
run(['docker','stop','-t','30','azerothai-classic-world','azerothai-classic-auth'])
|
||||
for name in ['azerothai-classic-world','azerothai-classic-auth']:
|
||||
run(['docker','rm','-f',name+'-staging'],False)
|
||||
run(['docker','rename',name,name+'-staging'])
|
||||
common=['--network','azerothai-classic-access','--restart','unless-stopped','--user','1000:1000','-v',str(root)+':/work','-w','/work/run/bin']
|
||||
run(['docker','create','--name','azerothai-classic-world',*common,'--cpus','2','--memory','3g','-p','8086:8086','azerothai-classic-builder:local','bash','-c','exec 3<>/work/run/console; exec ./mangosd -c /work/run/etc/mangosd.conf <&3'])
|
||||
run(['docker','create','--name','azerothai-classic-auth',*common,'--cpus','0.5','--memory','128m','-p','3724:3724','azerothai-classic-builder:local','./realmd','-c','/work/run/etc/realmd.conf'])
|
||||
for name in ['azerothai-classic-world','azerothai-classic-auth']:
|
||||
run(['docker','network','connect','azerothai-classic',name]);run(['docker','start',name])
|
||||
run(['docker','update','--restart','unless-stopped','azerothai-classic-db'])
|
||||
ip=run(['docker','inspect','--format','{{with index .NetworkSettings.Networks "azerothai-classic-access"}}{{.IPAddress}}{{end}}','azerothai-classic-world'])
|
||||
p=app/'.dev.vars';text=p.read_text();import re
|
||||
text=re.sub(r'^SOAP_URL=.*$','SOAP_URL=http://'+ip+':7879',text,flags=re.M);p.write_text(text)
|
||||
# Discard copied Wrath metrics; native Classic counters remain intact.
|
||||
databases=[p for p in (app/'.wrangler/state/v3/d1').rglob('*.sqlite') if p.name!='metadata.sqlite']
|
||||
assert len(databases)==1
|
||||
db=sqlite3.connect(databases[0]);backup=sqlite3.connect(archive/'classic-before-cutover.sqlite');db.backup(backup);backup.close()
|
||||
names=[x[0] for x in db.execute("SELECT name FROM sqlite_master WHERE type='table'")]
|
||||
for name in names:
|
||||
if name.startswith('insights_') or name=='operations_samples':db.execute('DELETE FROM "'+name+'"')
|
||||
db.commit();db.close()
|
||||
for name in classic:
|
||||
p=units/(name+'.service');text=p.read_text().replace('8791','8787').replace('RUNTIME_HOST=127.0.0.1','RUNTIME_HOST=0.0.0.0');p.write_text(text)
|
||||
p=root/'staging-worker.env';p.write_text(p.read_text().replace('8791','8787'))
|
||||
p=units/'azerothai-spectator.service';p.write_text(p.read_text().replace('/home/endami/AzerothAI',str(app)).replace('After=network-online.target azerothai-runtime.service','After=network-online.target azerothai-classic-runtime.service'))
|
||||
run(['systemctl','--user','daemon-reload']);run(['systemctl','--user','enable','--now',*classic,'azerothai-spectator'])
|
||||
run(['docker','run','-d','--name','azerothai-dashboard-proxy','--restart','unless-stopped','--network','azerothai-mini','--add-host','host.docker.internal:host-gateway','--memory','128m','-p','8788:80','-v',str(home/'.config/azerothai/nginx.conf')+':/etc/nginx/conf.d/default.conf:ro','-v',str(app/'dist/client')+':/srv:ro','nginx:alpine'])
|
||||
status('Classic endpoints active; waiting for all 50 fresh observations')
|
||||
for _ in range(36):
|
||||
try:
|
||||
current=json.load(urllib.request.urlopen('http://127.0.0.1:8787/api/state',timeout=5));bs=current['bots']
|
||||
if len(bs)==50 and all(b['status']=='online' and time.time()-b['world']['at']<20 for b in bs):break
|
||||
except Exception:pass
|
||||
time.sleep(5)
|
||||
else:raise RuntimeError('Classic did not restore all 50 fresh observations')
|
||||
assert sum(b['world']['team']==0 for b in bs)==25 and sum(b['world']['team']==1 for b in bs)==25
|
||||
run(['python3',str(root/'adapter/check-auth.py'),'--root',str(root),'--port','3724'])
|
||||
status('Classic cutover complete; 50 fresh bots, 25 Alliance and 25 Horde; login verified',cutover=True,backup=str(archive))
|
||||
except Exception as error:
|
||||
if changed:
|
||||
run(['systemctl','--user','stop',*classic,'azerothai-spectator'],False)
|
||||
run(['docker','stop','azerothai-classic-world','azerothai-classic-auth'],False)
|
||||
if run(['docker','ps','-a','--filter','name=^azerothai-dashboard-proxy-wrath-rollback$','--format','{{.Names}}'],False):
|
||||
run(['docker','rm','-f','azerothai-dashboard-proxy'],False);run(['docker','rename','azerothai-dashboard-proxy-wrath-rollback','azerothai-dashboard-proxy'],False)
|
||||
for file in archive.glob('azerothai*'):shutil.copy2(file,units/file.name)
|
||||
run(['systemctl','--user','daemon-reload'],False)
|
||||
run(['docker','start','ac-worldserver','ac-authserver','azerothai-dashboard-proxy'],False)
|
||||
run(['systemctl','--user','enable','--now',*old,'azerothai-spectator'],False)
|
||||
status('Classic cutover blocked; former Wrath endpoints restored',error=str(error),cutover=False)
|
||||
raise
|
||||
|
||||
if __name__=='__main__':
|
||||
p=argparse.ArgumentParser();p.add_argument('--root',type=pathlib.Path,required=True);main(p.parse_args().root.resolve())
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Classic host readings only; never runs Wrath maintenance SQL."""
|
||||
import argparse,json,pathlib,shutil,subprocess,time,urllib.request
|
||||
p=argparse.ArgumentParser();p.add_argument('--root',type=pathlib.Path,required=True);p.add_argument('--url',default='http://127.0.0.1:8787');a=p.parse_args()
|
||||
v={}
|
||||
for line in (a.root/'app-staging/.dev.vars').read_text().splitlines():
|
||||
if '=' in line:
|
||||
k,value=line.split('=',1);v[k]=value.strip().strip('"\'')
|
||||
def cpu():
|
||||
values=list(map(int,pathlib.Path('/proc/stat').read_text().splitlines()[0].split()[1:]));return sum(values[:8]),sum(values[3:5])
|
||||
x=cpu();time.sleep(1);y=cpu()
|
||||
m={l.split(':')[0]:int(l.split()[1])/1024 for l in pathlib.Path('/proc/meminfo').read_text().splitlines()}
|
||||
model=False
|
||||
try:
|
||||
with urllib.request.urlopen(v['LLM_URL'].rstrip('/')+'/api/tags',timeout=5) as response:model=response.status==200
|
||||
except Exception:pass
|
||||
body=dict(host='ubuntu',at=time.time(),cpuPercent=max(0,min(100,100*(1-(y[1]-x[1])/max(1,y[0]-x[0])))),memoryTotalMB=m['MemTotal'],memoryAvailableMB=m['MemAvailable'],diskFreeGB=shutil.disk_usage(a.root).free/1024**3,modelAvailable=model,runtimeService=subprocess.check_output(['systemctl','--user','is-active','azerothai-classic-runtime'],text=True).strip(),maintenance='Classic telemetry active; Wrath maintenance disabled')
|
||||
req=urllib.request.Request(a.url+'/api/operations',data=json.dumps(body).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+v['AUTONOMY_TOKEN']})
|
||||
with urllib.request.urlopen(req,timeout=15) as response:assert response.status==200
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install the version-checked Classic adapter into an isolated CMaNGOS checkout."""
|
||||
import argparse
|
||||
import pathlib
|
||||
import shutil
|
||||
|
||||
def patch(path, anchor, replacement):
|
||||
text = path.read_text()
|
||||
if replacement in text:
|
||||
return
|
||||
if text.count(anchor) != 1:
|
||||
raise RuntimeError(f"Upstream source changed: {path.name}")
|
||||
path.write_text(text.replace(anchor, replacement))
|
||||
|
||||
def install(source):
|
||||
here = pathlib.Path(__file__).resolve().parent
|
||||
for path in (here / "native").glob("AzerothAI.*"):
|
||||
shutil.copy2(path, source / "src/game/World" / path.name)
|
||||
for relative, anchor, invocation in [
|
||||
("src/game/World/World.cpp", " sMapMgr.Update(diff);", "AzerothAIUpdate(diff);"),
|
||||
("src/game/Entities/Unit.cpp", " uint32 health = victim->GetHealth();", "damage = AzerothAIBoundDamage(victim, damage);")
|
||||
]:
|
||||
path = source / relative
|
||||
include = '#ifdef ENABLE_PLAYERBOTS\n#include "World/AzerothAI.h"\n#endif\n'
|
||||
if include not in path.read_text():
|
||||
path.write_text(include + path.read_text())
|
||||
patch(path, anchor, anchor + "\n#ifdef ENABLE_PLAYERBOTS\n " + invocation + "\n#endif")
|
||||
# Upstream treats unknown gear modes as generated equipment. Explicitly
|
||||
# preserve ordinary character-creation equipment for our level-one roster.
|
||||
path = source / "src/modules/PlayerBots/playerbot/PlayerbotMgr.cpp"
|
||||
anchor = ' sRandomPlayerbotMgr.SetValue(botGuid, "create gear", 1, gear);'
|
||||
patch(path, anchor, ' if (gear != "none")\n ' + anchor)
|
||||
path = source / "src/game/Entities/CharacterHandler.cpp"
|
||||
include = '#ifdef ENABLE_PLAYERBOTS\n#include "World/AzerothAI.h"\n#endif\n'
|
||||
if include not in path.read_text():
|
||||
path.write_text(include + path.read_text())
|
||||
anchor = 'void PlayerbotHolder::AddPlayerBot(uint32 playerGuid, uint32 masterAccount)\n{'
|
||||
patch(path, anchor, anchor + '\n if (!AzerothAIAllowBotLogin(playerGuid)) return;')
|
||||
|
||||
# Hooks record successful native outcomes, not inferred command success.
|
||||
player = source / "src/game/Entities/Player.cpp"
|
||||
loot = source / "src/game/Loot/LootMgr.cpp"
|
||||
for path in [player, loot]:
|
||||
if include not in path.read_text():
|
||||
path.write_text(include + path.read_text())
|
||||
hooks = [
|
||||
(player, " // resend quests status directly\n UpdateForQuestWorldObjects();", 'AzerothAIEvent(this,"player.quest.reward",quest_id);'),
|
||||
(player, "void Player::CompleteQuest(uint32 quest_id)\n{\n if (quest_id)\n {\n SetQuestStatus(quest_id, QUEST_STATUS_COMPLETE);", 'AzerothAIEvent(this,"player.quest.complete",quest_id);'),
|
||||
(player, " SetDeathState(CORPSE);", 'AzerothAIEvent(this,"player.death");'),
|
||||
(loot, " plr->ModifyMoney(money_per_player);", 'AzerothAIEvent(plr,"player.loot.money",0,money_per_player);'),
|
||||
(loot, " player->ModifyMoney(m_gold);", 'AzerothAIEvent(player,"player.loot.money",0,m_gold);'),
|
||||
]
|
||||
for path, anchor, call in hooks:
|
||||
if call:
|
||||
patch(path, anchor, anchor + "\n#ifdef ENABLE_PLAYERBOTS\n " + call + "\n#endif")
|
||||
for anchor, call in [
|
||||
(" if (!applySickness)\n return;", 'AzerothAIEvent(this,"player.resurrect");'),
|
||||
(" WorldPacket data(SMSG_LEARNED_SPELL, 4);", 'AzerothAIEvent(this,"player.spell.learn",spell_id);'),
|
||||
(" WorldPacket data(SMSG_ITEM_PUSH_RESULT, (8 + 4 + 4 + 4 + 1 + 4 + 4 + 4 + 4 + 4));", 'if (!received && !created) AzerothAIEvent(this,"player.loot.item",item->GetEntry(),count);'),
|
||||
]:
|
||||
patch(player, anchor, "#ifdef ENABLE_PLAYERBOTS\n " + call + "\n#endif\n" + anchor)
|
||||
|
||||
chat=source / "src/game/Chat/ChatHandler.cpp"
|
||||
if include not in chat.read_text():chat.write_text(include+chat.read_text())
|
||||
for anchor,call in [
|
||||
(' GetPlayer()->Say(msg, lang);', 'if(lang!=LANG_ADDON && !m_anticheat->IsSilenced()) AzerothAIChat(GetPlayer(),msg);'),
|
||||
(' if (lang != LANG_ADDON && !m_anticheat->IsSilenced())\n m_anticheat->Whisper(msg, player->GetObjectGuid());', 'if(lang!=LANG_ADDON && !m_anticheat->IsSilenced()) AzerothAIChat(GetPlayer(),msg,player);')
|
||||
]:patch(chat,anchor,anchor+'\n#ifdef ENABLE_PLAYERBOTS\n '+call+'\n#endif')
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("source", type=pathlib.Path)
|
||||
install(parser.parse_args().source.resolve())
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install isolated, restartable Classic dashboard/relay/autonomy user services.
|
||||
|
||||
Prepare staging state first and stop any manually started staging processes.
|
||||
This does not enable game login, start authentication, or change live services.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
|
||||
|
||||
def main(root):
|
||||
app = root / 'app-staging'
|
||||
node = pathlib.Path.home() / '.local/bin/node'
|
||||
for path in [node, app / '.dev.vars', root / 'adapter/relay.py']:
|
||||
if not path.is_file():
|
||||
raise RuntimeError('Missing prerequisite: ' + str(path))
|
||||
if any(c in str(root) for c in [' ', '\n', '"', "'", '%']):
|
||||
raise RuntimeError('Use a simple absolute staging path')
|
||||
values = {}
|
||||
for line in (app / '.dev.vars').read_text().splitlines():
|
||||
if '=' in line and not line.lstrip().startswith('#'):
|
||||
key, value = line.split('=', 1)
|
||||
values[key.strip()] = value.strip().strip('"\'')
|
||||
token = values['AUTONOMY_TOKEN']
|
||||
if not token or any(c in token for c in ['\n', '\r', '"', '\\']):
|
||||
raise RuntimeError('Invalid autonomy token format')
|
||||
environment = root / 'staging-worker.env'
|
||||
fd = os.open(environment, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, 'w') as out:
|
||||
out.write('AUTONOMY_TOKEN="' + token + '"\nAZEROTHAI_URL=http://127.0.0.1:8791\n')
|
||||
environment.chmod(0o600)
|
||||
units = pathlib.Path.home() / '.config/systemd/user'
|
||||
units.mkdir(parents=True, exist_ok=True)
|
||||
commands = {
|
||||
'runtime': (f'{node} {app}/scripts/start-local-runtime.mjs', 'Environment=RUNTIME_PORT=8791 RUNTIME_HOST=127.0.0.1\n'),
|
||||
'relay': (f'/usr/bin/sg docker -c "/usr/bin/python3 {root}/adapter/relay.py --root {root} --url http://127.0.0.1:8791 --env-file {app}/.dev.vars"', ''),
|
||||
'autonomy': (f'{node} {app}/scripts/autonomy-worker.mjs', f'EnvironmentFile={environment}\n'),
|
||||
}
|
||||
names = []
|
||||
for kind, (command, extra) in commands.items():
|
||||
name = f'azerothai-classic-{kind}.service'
|
||||
names.append(name)
|
||||
after = 'network.target' if kind == 'runtime' else 'azerothai-classic-runtime.service'
|
||||
(units / name).write_text(f'''[Unit]
|
||||
Description=AzerothAI Classic staging {kind}
|
||||
After={after}
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory={app}
|
||||
Environment=PATH={node.parent}:/usr/local/bin:/usr/bin:/bin
|
||||
{extra}ExecStart={command}
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=20
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
''')
|
||||
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
|
||||
subprocess.run(['systemctl', '--user', 'enable', '--now', *names], check=True)
|
||||
subprocess.run(['systemctl', '--user', 'is-active', *names], check=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--root', type=pathlib.Path, required=True)
|
||||
main(parser.parse_args().root.resolve())
|
||||
@@ -0,0 +1,193 @@
|
||||
#include "AzerothAI.h"
|
||||
#include "Entities/Player.h"
|
||||
#include "World/World.h"
|
||||
#include "Server/WorldSession.h"
|
||||
#include "Server/DBCStores.h"
|
||||
#include "Globals/ObjectMgr.h"
|
||||
#include "Globals/SharedDefines.h"
|
||||
#include "Groups/Group.h"
|
||||
#include "Config/Config.h"
|
||||
#include "Database/DatabaseEnv.h"
|
||||
#include "playerbot/PlayerbotAI.h"
|
||||
#include "playerbot/PlayerbotAIConfig.h"
|
||||
#include "playerbot/RandomPlayerbotMgr.h"
|
||||
#include <atomic>
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
#include <ctime>
|
||||
|
||||
namespace {
|
||||
struct Resident { uint32_t guid, account; std::string name; bool armor; };
|
||||
std::map<uint32_t, Resident> roster;
|
||||
std::atomic<uint32_t> armorGuid{0}, armorAccount{0};
|
||||
std::string sql(std::string text) { CharacterDatabase.escape_string(text); return text; }
|
||||
std::string quoted(const std::string& text) {
|
||||
std::ostringstream out; out << '"';
|
||||
for (unsigned char c : text) {
|
||||
if (c == '"' || c == '\\') out << '\\' << c;
|
||||
else if (c < 32) out << "\\u" << std::hex << std::setw(4) << std::setfill('0') << unsigned(c);
|
||||
else out << c;
|
||||
}
|
||||
out << '"'; return out.str();
|
||||
}
|
||||
bool identified(Player* p) {
|
||||
if (!p || !p->IsInWorld()) return false;
|
||||
auto r = roster.find(p->GetGUIDLow());
|
||||
return r != roster.end() && p->GetName() == r->second.name && p->GetSession()->GetAccountId() == r->second.account
|
||||
&& p->GetPlayerbotAI();
|
||||
}
|
||||
bool managed(Player* p) { return identified(p) && !p->GetPlayerbotAI()->IsRealPlayer() && !p->GetPlayerbotAI()->HasRealPlayerMaster(); }
|
||||
bool safeGroup(Player* p) {
|
||||
Group* g = p->GetGroup(); if (!g) return true;
|
||||
unsigned count = 0;
|
||||
for (GroupReference* r = g->GetFirstMember(); r; r = r->next()) {
|
||||
if (!managed(r->getSource())) return false;
|
||||
++count;
|
||||
}
|
||||
return count == g->GetMembersCount();
|
||||
}
|
||||
bool free(Player* p) { return managed(p) && p->IsAlive() && !p->IsInCombat() && !p->IsTaxiFlying() && !p->IsNonMeleeSpellCasted(false); }
|
||||
bool nearby(Player* a, Player* b, float range) { return free(a) && free(b) && a->GetTeam() == b->GetTeam() && a->GetMapId() == b->GetMapId() && a->GetDistance(b) <= range; }
|
||||
void receipt(const std::string& id, bool ok, const std::string& result) {
|
||||
CharacterDatabase.PExecute("UPDATE azerothai_commands SET status='%s',result='%s' WHERE id='%s' AND status='processing'", ok ? "completed" : "failed", sql(result).c_str(), sql(id).c_str());
|
||||
}
|
||||
void commands() {
|
||||
auto q = CharacterDatabase.Query("SELECT id,guid,account_id,name,kind,argument,peer_guid,created_at,peer_account_id,peer_name,expires_at FROM azerothai_commands WHERE status='pending' ORDER BY created_at LIMIT 8");
|
||||
if (!q) return;
|
||||
do {
|
||||
Field* f=q->Fetch(); std::string id=f[0].GetString(), kind=f[4].GetString(), arg=f[5].GetString();
|
||||
// One world-thread consumer claims durably before any game effect.
|
||||
if (!CharacterDatabase.DirectPExecute("UPDATE azerothai_commands SET status='processing' WHERE id='%s' AND status='pending'",sql(id).c_str())) continue;
|
||||
Player* p=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,f[1].GetUInt32()));
|
||||
Player* peer=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,f[6].GetUInt32()));
|
||||
if (!managed(p) || p->GetSession()->GetAccountId()!=f[2].GetUInt32() || std::string(p->GetName())!=f[3].GetString() || std::time(nullptr)-f[7].GetUInt32()>90 || std::time(nullptr)>f[10].GetUInt32()) { receipt(id,false,"Stale command or managed identity unavailable"); continue; }
|
||||
bool humanReply=kind=="reply-say" || kind=="reply-whisper";
|
||||
bool peerAllowed=humanReply ? peer && peer->IsInWorld() && (!peer->GetPlayerbotAI() || peer->GetPlayerbotAI()->IsRealPlayer()) : managed(peer);
|
||||
if (f[6].GetUInt32() && (!peerAllowed || peer->GetSession()->GetAccountId()!=f[8].GetUInt32() || std::string(peer->GetName())!=f[9].GetString())) {receipt(id,false,"Peer identity changed or protected");continue;}
|
||||
if (humanReply) {
|
||||
if (arg.empty() || arg.size()>1000 || !peer || p->GetTeam()!=peer->GetTeam() || (kind=="reply-say" && (p->GetMapId()!=peer->GetMapId() || p->GetDistance(peer)>25))) {receipt(id,false,"Player conversation no longer available");continue;}
|
||||
if(kind=="reply-whisper")p->Whisper(arg,LANG_UNIVERSAL,peer->GetObjectGuid());
|
||||
else p->Say(arg,p->GetTeam()==ALLIANCE?LANG_COMMON:LANG_ORCISH);
|
||||
receipt(id,true,"In-game player reply sent");
|
||||
} else if (kind=="party") {
|
||||
if (!nearby(p,peer,35) || !safeGroup(p) || !safeGroup(peer) || peer->GetGroup() || peer->GetGroupInvite() || p->GetGroupInvite() || (p->GetGroup() && (p->GetGroup()->GetMembersCount()>=5 || p->GetGroup()->GetLeaderGuid()!=p->GetObjectGuid()))) { receipt(id,false,"Party participants unavailable or protected"); continue; }
|
||||
WorldPacket invite(CMSG_GROUP_INVITE,32); invite << std::string(peer->GetName()); p->GetSession()->HandleGroupInviteOpcode(invite);
|
||||
if (peer->GetGroupInvite()) { WorldPacket accept(CMSG_GROUP_ACCEPT,0); peer->GetSession()->HandleGroupAcceptOpcode(accept); }
|
||||
receipt(id,p->GetGroup() && p->GetGroup()==peer->GetGroup(),"Native party invitation processed");
|
||||
} else if (kind=="recruit") {
|
||||
if (!nearby(p,peer,35) || !p->GetGuildId() || p->GetRank()!=0 || peer->GetGuildId() || peer->GetGuildIdInvited()) {receipt(id,false,"Guild participants unavailable or protected");continue;}
|
||||
WorldPacket invite(CMSG_GUILD_INVITE,32); invite << std::string(peer->GetName());p->GetSession()->HandleGuildInviteOpcode(invite);
|
||||
if(peer->GetGuildIdInvited()==p->GetGuildId()){WorldPacket accept(CMSG_GUILD_ACCEPT,0);peer->GetSession()->HandleGuildAcceptOpcode(accept);}
|
||||
receipt(id,peer->GetGuildId()==p->GetGuildId(),"Native guild invitation processed");
|
||||
} else if (kind=="leave") {
|
||||
if (!free(p) || !safeGroup(p) || !p->GetGroup() || std::to_string(p->GetGroup()->GetId())!=arg) { receipt(id,false,"Party changed or protected"); continue; }
|
||||
WorldPacket packet(CMSG_GROUP_DISBAND,0); p->GetSession()->HandleGroupDisbandOpcode(packet); receipt(id,!p->GetGroup(),"Native departure processed");
|
||||
} else if (kind=="say") {
|
||||
if (!nearby(p,peer,25) || arg.empty() || arg.size()>1000) { receipt(id,false,"Conversation participants unavailable nearby"); continue; }
|
||||
p->Say(arg,p->GetTeam()==ALLIANCE?LANG_COMMON:LANG_ORCISH); receipt(id,true,"In-game speech sent");
|
||||
} else if (kind=="intent") {
|
||||
if (!p->IsAlive() || p->IsTaxiFlying() || p->IsInCombat() || !safeGroup(p)) { receipt(id,false,"Native recovery, travel or combat takes priority"); continue; }
|
||||
PlayerbotAI* ai=p->GetPlayerbotAI(); bool accepted=false;
|
||||
if (arg=="quest" || arg=="explore") { ai->ChangeStrategy("-stay,+rpg,+grind",BotState::BOT_STATE_NON_COMBAT); ai->DoNextAction(); accepted=true; }
|
||||
else if (arg=="rest") { accepted=ai->DoSpecificAction("eat") || ai->DoSpecificAction("drink"); }
|
||||
else if (arg=="trade") { accepted=ai->DoSpecificAction("repair") || ai->DoSpecificAction("sell") || ai->DoSpecificAction("buy"); }
|
||||
else if (arg=="socialize") { p->HandleEmoteCommand(EMOTE_ONESHOT_WAVE); accepted=true; }
|
||||
receipt(id,accepted,accepted?"Intention submitted to native AI; outcome requires observation":"Native AI did not accept this intention");
|
||||
} else receipt(id,false,"Unsupported Classic command");
|
||||
} while(q->NextRow());
|
||||
}
|
||||
void snapshot(Player* p) {
|
||||
std::ostringstream out; std::string members, nearbyNames; Group* group=p->GetGroup();
|
||||
if(group) for(GroupReference* r=group->GetFirstMember();r;r=r->next()) if(Player* m=r->getSource()) { if(!members.empty())members+=","; members+=m->GetName(); }
|
||||
for(auto const& r:roster) if(r.first!=p->GetGUIDLow()) { Player* m=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,r.first)); if(managed(m)&&m->GetTeam()==p->GetTeam()&&m->GetMapId()==p->GetMapId()&&p->GetDistance(m)<=35) { if(!nearbyNames.empty())nearbyNames+=","; nearbyNames+=m->GetName(); } }
|
||||
out << "{\"isBot\":true,\"characterGuid\":" << p->GetGUIDLow() << ",\"accountId\":" << p->GetSession()->GetAccountId() << ",\"name\":" << quoted(p->GetName())
|
||||
<< ",\"level\":" << p->GetLevel() << ",\"race\":" << unsigned(p->getRace()) << ",\"class\":" << unsigned(p->getClass())
|
||||
<< ",\"team\":" << (p->GetTeam()==ALLIANCE?0:1) << ",\"mapId\":" << p->GetMapId() << ",\"zoneId\":" << p->GetZoneId()
|
||||
<< ",\"x\":" << p->GetPositionX() << ",\"y\":" << p->GetPositionY() << ",\"healthPct\":" << p->GetHealthPercent()
|
||||
<< ",\"xp\":" << p->GetUInt32Value(PLAYER_XP) << ",\"observedAt\":" << std::time(nullptr)
|
||||
<< ",\"isDead\":" << (p->IsAlive()?"false":"true") << ",\"isGhost\":" << (p->HasFlag(PLAYER_FLAGS,PLAYER_FLAGS_GHOST)?"true":"false")
|
||||
<< ",\"inCombat\":" << (p->IsInCombat()?"true":"false") << ",\"casting\":" << (p->IsNonMeleeSpellCasted(false)?"true":"false")
|
||||
<< ",\"taxi\":" << (p->IsTaxiFlying()?"true":"false") << ",\"humanControlled\":" << (managed(p)?"false":"true")
|
||||
<< ",\"groupId\":" << quoted(group?std::to_string(group->GetId()):"") << ",\"groupNames\":" << quoted(members)
|
||||
<< ",\"nearbyBots\":" << quoted(nearbyNames) << ",\"guildId\":" << p->GetGuildId() << ",\"guildRank\":" << p->GetRank()
|
||||
<< ",\"quests\":[";
|
||||
bool first=true;for(uint8 i=0;i<MAX_QUEST_LOG_SIZE;++i) { uint32 id=p->GetQuestSlotQuestId(i);if(id){if(!first)out<<',';out<<id;first=false;} }
|
||||
out << ']'; if(p->GetPowerType()==POWER_MANA && p->GetMaxPower(POWER_MANA)) out << ",\"manaPct\":" << 100.0*p->GetPower(POWER_MANA)/p->GetMaxPower(POWER_MANA);
|
||||
if(auto area=GetAreaEntryByAreaID(p->GetZoneId())) out << ",\"zoneName\":" << quoted(area->area_name[0]);
|
||||
out << '}';
|
||||
CharacterDatabase.PExecute("REPLACE INTO azerothai_snapshots (guid,at,payload) VALUES (%u,UNIX_TIMESTAMP(),'%s')",p->GetGUIDLow(),sql(out.str()).c_str());
|
||||
}
|
||||
}
|
||||
uint32_t AzerothAIBoundDamage(Unit* victim,uint32_t damage) {
|
||||
if(victim && victim->GetTypeId()==TYPEID_PLAYER && victim->GetGUIDLow()==armorGuid.load()) {
|
||||
Player* p=static_cast<Player*>(victim);
|
||||
if(p->GetSession()->GetAccountId()==armorAccount.load() && p->GetPlayerbotAI() && !p->GetPlayerbotAI()->IsRealPlayer()) return std::min(damage,p->GetHealth()>1?p->GetHealth()-1:0u);
|
||||
}
|
||||
return damage;
|
||||
}
|
||||
bool AzerothAIAllowBotLogin(uint32_t guid) {
|
||||
if(!sConfig.GetBoolDefault("AzerothAI.RestrictBotLogin",false))return true;
|
||||
auto count=CharacterDatabase.Query("SELECT COUNT(*) FROM azerothai_roster WHERE enabled=1");
|
||||
if(!count || count->Fetch()[0].GetUInt32()>50)return false;
|
||||
auto identity=CharacterDatabase.PQuery("SELECT c.guid FROM characters c JOIN azerothai_roster r ON r.guid=c.guid AND r.account_id=c.account AND r.name=c.name WHERE r.enabled=1 AND c.guid=%u",guid);
|
||||
return bool(identity);
|
||||
}
|
||||
void AzerothAIUpdate(uint32_t diff) {
|
||||
static uint32_t elapsed=0; elapsed+=diff;if(elapsed<5000)return;elapsed=0;
|
||||
if(!sConfig.GetBoolDefault("AzerothAI.Enable",false)){armorGuid=0;armorAccount=0;return;}
|
||||
static bool started=false; if(!started){if(!CharacterDatabase.DirectExecute("UPDATE azerothai_commands SET status='failed',result='Server restarted during execution; outcome unknown, command not replayed' WHERE status='processing'"))return;started=true;}
|
||||
auto q=CharacterDatabase.Query("SELECT guid,account_id,name,plot_armor FROM azerothai_roster WHERE enabled=1 ORDER BY guid LIMIT 51");
|
||||
if(!q || q->GetRowCount()>50){roster.clear();armorGuid=0;armorAccount=0;return;}
|
||||
roster.clear();armorGuid=0;armorAccount=0;
|
||||
do { Field* f=q->Fetch();Resident r{f[0].GetUInt32(),f[1].GetUInt32(),f[2].GetString(),f[3].GetBool()};if(!r.guid||!r.account)continue;roster.emplace(r.guid,r);if(r.armor && r.name=="Zaku"){armorAccount=r.account;armorGuid=r.guid;} }while(q->NextRow());
|
||||
// Population is drawn exclusively from the explicit identity allowlist.
|
||||
static std::map<uint32_t,time_t> loginAttempt;
|
||||
for(auto const& pair:roster){auto const&r=pair.second;Player* p=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,r.guid));
|
||||
if(!p){if(std::time(nullptr)-loginAttempt[r.guid]>=60){auto identity=CharacterDatabase.PQuery("SELECT guid FROM characters WHERE guid=%u AND account=%u AND name='%s'",r.guid,r.account,sql(r.name).c_str());if(identity){sRandomPlayerbotMgr.AddPlayerBot(r.guid,0);loginAttempt[r.guid]=std::time(nullptr);}}continue;}
|
||||
if(identified(p))snapshot(p);
|
||||
// Honor the configured native auto-learn policy even for externally
|
||||
// maintained bot sessions. Do not grant spells through a second path.
|
||||
static std::map<uint32_t,uint32_t> trainedLevel;
|
||||
if(sPlayerbotAIConfig.autoLearnTrainerSpells && free(p) && trainedLevel[r.guid]!=p->GetLevel()){
|
||||
if(p->GetPlayerbotAI()->DoSpecificAction("auto learn spell"))trainedLevel[r.guid]=p->GetLevel();
|
||||
}
|
||||
}
|
||||
commands();
|
||||
}
|
||||
|
||||
void AzerothAIEvent(Player* p, const char* kind, uint32_t value, uint32_t amount) {
|
||||
if (!sConfig.GetBoolDefault("AzerothAI.Enable",false) || !managed(p)) return;
|
||||
std::string eventKind(kind), column;
|
||||
if(eventKind=="player.quest.complete")column="quests";
|
||||
else if(eventKind=="player.quest.reward")column="rewarded";
|
||||
else if(eventKind=="player.loot.item")column="lootItems";
|
||||
else if(eventKind=="player.loot.money")column="lootedCopper";
|
||||
else if(eventKind=="player.death")column="nativeDeaths";
|
||||
else if(eventKind=="player.spell.learn")column="learnedSpells";
|
||||
else if(eventKind=="player.resurrect")column="resurrections";
|
||||
if(!column.empty())CharacterDatabase.PExecute("INSERT INTO azerothai_counters(guid,%s) VALUES(%u,%u) ON DUPLICATE KEY UPDATE %s=%s+%u",column.c_str(),p->GetGUIDLow(),amount,column.c_str(),column.c_str(),amount);
|
||||
if(eventKind=="player.loot.item")CharacterDatabase.PExecute("INSERT INTO azerothai_counters(guid,lootEvents) VALUES(%u,1) ON DUPLICATE KEY UPDATE lootEvents=lootEvents+1",p->GetGUIDLow());
|
||||
std::ostringstream out;
|
||||
out << "{\"isBot\":true,\"characterGuid\":" << p->GetGUIDLow() << ",\"accountId\":" << p->GetSession()->GetAccountId()
|
||||
<< ",\"name\":" << quoted(p->GetName()) << ",\"level\":" << p->GetLevel() << ",\"observedAt\":" << std::time(nullptr)
|
||||
<< ",\"value\":" << value << ",\"amount\":" << amount;
|
||||
if(auto area=GetAreaEntryByAreaID(p->GetZoneId()))out << ",\"zoneName\":" << quoted(area->area_name[0]);
|
||||
out << '}';
|
||||
CharacterDatabase.PExecute("INSERT INTO azerothai_events(at,kind,payload) VALUES(UNIX_TIMESTAMP(),'%s','%s')",sql(eventKind).c_str(),sql(out.str()).c_str());
|
||||
}
|
||||
|
||||
void AzerothAIChat(Player* p, const std::string& message, Player* receiver) {
|
||||
if(!sConfig.GetBoolDefault("AzerothAI.Enable",false) || !p || !p->IsInWorld() || (p->GetPlayerbotAI() && !p->GetPlayerbotAI()->IsRealPlayer()) || message.empty() || message.size()>500 || message[0]=='.' || message[0]=='!')return;
|
||||
if(receiver && !managed(receiver))return;
|
||||
std::string names;
|
||||
for(auto const& r:roster){Player* bot=sObjectMgr.GetPlayer(ObjectGuid(HIGHGUID_PLAYER,r.first));if(managed(bot) && bot->GetTeam()==p->GetTeam() && bot->GetMapId()==p->GetMapId() && bot->GetDistance(p)<=25){if(!names.empty())names+=",";names+=bot->GetName();}}
|
||||
if(!receiver && names.empty())return;
|
||||
std::ostringstream out;
|
||||
out << "{\"isBot\":false,\"characterGuid\":" << p->GetGUIDLow() << ",\"accountId\":" << p->GetSession()->GetAccountId() << ",\"name\":" << quoted(p->GetName())
|
||||
<< ",\"message\":" << quoted(message) << ",\"nearbyBots\":" << quoted(names) << ",\"receiver\":" << quoted(receiver?receiver->GetName():"") << ",\"receiverIsBot\":" << (receiver?"true":"false");
|
||||
if(auto area=GetAreaEntryByAreaID(p->GetZoneId()))out << ",\"zoneName\":" << quoted(area->area_name[0]);
|
||||
out << '}';
|
||||
CharacterDatabase.PExecute("INSERT INTO azerothai_events(at,kind,payload) VALUES(UNIX_TIMESTAMP(),'%s','%s')",receiver?"player.whisper":"player.chat",sql(out.str()).c_str());
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
class Unit;
|
||||
void AzerothAIUpdate(uint32_t diff);
|
||||
uint32_t AzerothAIBoundDamage(Unit* victim, uint32_t damage);
|
||||
bool AzerothAIAllowBotLogin(uint32_t guid);
|
||||
class Player;
|
||||
void AzerothAIEvent(Player* player, const char* kind, uint32_t value = 0, uint32_t amount = 1);
|
||||
|
||||
#include <string>
|
||||
void AzerothAIChat(Player* player, const std::string& message, Player* receiver = nullptr);
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Produce a validated level-one Vanilla roster; never modify live characters."""
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
RACES = {"Human":1,"Orc":2,"Dwarf":3,"Night Elf":4,"Undead":5,"Tauren":6,"Gnome":7,"Troll":8}
|
||||
CLASSES = {"Warrior":1,"Paladin":2,"Hunter":3,"Rogue":4,"Priest":5,"Shaman":7,"Mage":8,"Warlock":9,"Druid":11}
|
||||
ALLOWED = {1:{1,2,4,5,8,9},2:{1,3,4,7,9},3:{1,2,3,4,5},4:{1,3,4,5,11},5:{1,4,5,8,9},6:{1,3,7,11},7:{1,4,8,9},8:{1,3,4,5,7,8}}
|
||||
ALLIANCE = {"Human","Dwarf","Night Elf","Gnome","Draenei"}
|
||||
|
||||
def plan(bots):
|
||||
if len(bots) != 50 or len({b['name'].lower() for b in bots}) != 50:
|
||||
raise ValueError("Exactly 50 unique active identities required")
|
||||
result = []
|
||||
for b in bots:
|
||||
if not re.fullmatch(r'[A-Za-z]{2,12}',b['name']):
|
||||
raise ValueError("Invalid native character name")
|
||||
race, role = b['race'], b['role']
|
||||
team = 0 if race in ALLIANCE else 1
|
||||
if role == 'Death Knight': role = 'Warrior'
|
||||
if team == 0 and role == 'Shaman': role = 'Paladin'
|
||||
if team == 1 and role == 'Paladin': role = 'Shaman'
|
||||
if race == 'Draenei': race = 'Dwarf' if role in ('Hunter','Paladin','Warrior') else 'Human'
|
||||
if race == 'Blood Elf': race = 'Tauren' if role == 'Shaman' else 'Undead'
|
||||
race_id, class_id = RACES[race], CLASSES[role]
|
||||
if class_id not in ALLOWED[race_id]:
|
||||
raise ValueError(f"Invalid Vanilla combination: {b['name']} {race} {role}")
|
||||
if (race in ALLIANCE) != (team == 0): raise ValueError('Faction changed')
|
||||
result.append(dict(id=b['id'],name=b['name'],race=race,role=role,raceId=race_id,classId=class_id,team=team,level=1,
|
||||
oldRace=b['race'],oldRole=b['role'],oldLevel=b['level']))
|
||||
if collections.Counter(x['team'] for x in result) != {0:25,1:25}:
|
||||
raise ValueError('Roster must be 25 Alliance and 25 Horde')
|
||||
return result
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser=argparse.ArgumentParser()
|
||||
parser.add_argument('active',type=pathlib.Path)
|
||||
parser.add_argument('output',type=pathlib.Path)
|
||||
args=parser.parse_args()
|
||||
rows=plan(json.loads(args.active.read_text()))
|
||||
args.output.write_text(json.dumps(rows,indent=2)+'\n')
|
||||
print('Validated: 50 identities, 25 Alliance, 25 Horde, all start at level 1')
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare isolated Classic authentication, preserving ENDAMI's SRP credentials.
|
||||
Does not stop Wrath or alter any Wrath account. Secrets are never printed.
|
||||
"""
|
||||
import argparse,base64,hashlib,json,os,pathlib,re,secrets,subprocess
|
||||
|
||||
def main(root):
|
||||
credentials=json.loads((root/'database-credentials.json').read_text())
|
||||
def query(container,password,sql):
|
||||
result=subprocess.run(['docker','exec','-i','-e','MYSQL_PWD',container,'mysql','-uroot','-N','-B'],input=sql,text=True,capture_output=True,env=dict(os.environ,MYSQL_PWD=password))
|
||||
if result.returncode:raise RuntimeError('Authentication database operation failed')
|
||||
return result.stdout.strip()
|
||||
def classic(sql):return query('azerothai-classic-db',credentials['root'],sql)
|
||||
account_file=root/'classic-gm-credentials.json'
|
||||
if account_file.exists():gm=json.loads(account_file.read_text())
|
||||
else:
|
||||
gm={'username':'AZAIADMIN','password':secrets.token_hex(8)}
|
||||
account_file.write_text(json.dumps(gm));account_file.chmod(0o600)
|
||||
if gm['username'].startswith('AZCLASSIC'):
|
||||
gm['username']='AZAIADMIN'
|
||||
account_file.write_text(json.dumps(gm));account_file.chmod(0o600)
|
||||
salt=secrets.token_bytes(32)
|
||||
digest=hashlib.sha1((gm['username'].upper()+':'+gm['password'].upper()).encode()).digest()
|
||||
exponent=int.from_bytes(hashlib.sha1(salt+digest).digest(),'little')
|
||||
verifier=pow(7,exponent,int('894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7',16))
|
||||
assert re.fullmatch('[A-Z0-9]+',gm['username'])
|
||||
classic(f"INSERT INTO classicrealmd.account(username,s,v,gmlevel) VALUES ('{gm['username']}','{salt[::-1].hex()}','{verifier:X}',3) ON DUPLICATE KEY UPDATE gmlevel=3,s=VALUES(s),v=VALUES(v);")
|
||||
# Disable upstream sample logins without deleting accounts or characters.
|
||||
classic("UPDATE classicrealmd.account SET locked=1,lockedIp='disabled' WHERE id IN (1,2,3,4) AND username IN ('ADMINISTRATOR','GAMEMASTER','MODERATOR','PLAYER');")
|
||||
config=json.loads(subprocess.check_output(['docker','inspect','ac-database']))[0]
|
||||
env=dict(v.split('=',1) for v in config['Config']['Env'] if '=' in v)
|
||||
password=env.get('MYSQL_ROOT_PASSWORD') or env.get('MARIADB_ROOT_PASSWORD')
|
||||
row=query('ac-database',password,"SELECT a.username,HEX(a.salt),HEX(a.verifier),MAX(x.gmlevel) FROM acore_auth.account a JOIN acore_auth.account_access x ON x.id=a.id WHERE a.username='ENDAMI' GROUP BY a.id;").split('\t')
|
||||
if len(row)!=4 or row[0]!='ENDAMI':raise RuntimeError('Expected existing human GM account was not found')
|
||||
salt=bytes.fromhex(row[1])[::-1].hex();verifier=bytes.fromhex(row[2])[::-1].hex()
|
||||
classic(f"INSERT INTO classicrealmd.account(username,s,v,gmlevel) VALUES ('ENDAMI','{salt}','{verifier}',3) ON DUPLICATE KEY UPDATE s=VALUES(s),v=VALUES(v),gmlevel=3;")
|
||||
classic("UPDATE classicrealmd.realmlist SET name='AzerothAI Classic',address='10.0.0.125',port=8086,realmflags=0,allowedSecurityLevel=0 WHERE id=1;")
|
||||
text=(root/'run/etc/realmd.conf.dist').read_text()
|
||||
values={'LoginDatabaseInfo':'"azerothai-classic-db;3306;'+credentials['user']+';'+credentials['password']+';classicrealmd"','RealmServerPort':'3724','BindIP':'"0.0.0.0"','LogsDir':'"/work/run/logs"'}
|
||||
for key,value in values.items():
|
||||
pattern=r'^'+re.escape(key)+r'\s*=.*$';line=key+' = '+value
|
||||
text=re.sub(pattern,lambda _:line,text,flags=re.M) if re.search(pattern,text,re.M) else text+'\n'+line+'\n'
|
||||
path=root/'run/etc/realmd.conf';path.write_text(text);path.chmod(0o600)
|
||||
print('Classic GM credentials prepared; ENDAMI password verifier preserved; sample logins disabled.')
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser();parser.add_argument('--root',type=pathlib.Path,required=True)
|
||||
main(parser.parse_args().root.resolve())
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prepare the isolated dashboard database after native identities are verified."""
|
||||
import argparse
|
||||
import copy
|
||||
import datetime
|
||||
import json
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
def main(root,live):
|
||||
stage=root/'app-staging'
|
||||
bound=json.loads((root/'classic-roster-bound.json').read_text())
|
||||
assert len(bound)==50 and len({b['characterGuid'] for b in bound})==50
|
||||
with urllib.request.urlopen('http://127.0.0.1:8787/api/state',timeout=10) as response:state=json.load(response)
|
||||
# This archive captures memories accumulated since the initial migration export.
|
||||
(root/'wrath-state-before-dashboard-copy.json').write_text(json.dumps(state))
|
||||
old={b['id']:b for b in state['bots']}
|
||||
now=datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
bots=[]
|
||||
for identity in bound:
|
||||
bot=copy.deepcopy(old[identity['id']])
|
||||
bot.update(name=identity['name'],race=identity['race'],role=identity['role'],level=1,zone='Awaiting Classic observation',status='offline',simulationBand='dormant',decisions=0)
|
||||
for key in ['world','lastDecision','deathMemorySeenId','lastObservation','death']:bot.pop(key,None)
|
||||
bot['autonomy']={'managed':True,'characterGuid':identity['characterGuid'],'accountId':identity['accountId']}
|
||||
for memory in bot.get('memories',[]):memory['text']='[Historical memory from the previous Wrath realm; not current Classic progress] '+memory['text']
|
||||
bot.setdefault('memories',[]).append({'id':str(uuid.uuid4()),'text':f"I am starting a new life at level 1 as a {identity['race']} {identity['role']} in Classic Azeroth. Older memories describe my previous realm.",'importance':10,'source':'observation','at':now})
|
||||
bot['memories']=bot['memories'][-100:]
|
||||
bots.append(bot)
|
||||
state['bots']=bots;state['aleRosterAt']=0;state['revision']=state.get('revision',0)+1
|
||||
state['events']=[];state.pop('operations',None);state['population']={}
|
||||
state['settings']['population']['serverMax']=50
|
||||
state['settings']['hardcore']['enabled']=False
|
||||
state['community']={'feed':[],'evidence':[],'guilds':[],'partiesEnabled':True,'guildsEnabled':True}
|
||||
ids={b['id'] for b in bots}
|
||||
for home in state.get('homes',[]):home['residentIds']=[id for id in home.get('residentIds',[]) if id in ids]
|
||||
source=list((live/'.wrangler/state/v3/d1').rglob('*.sqlite'))
|
||||
candidates=[]
|
||||
for path in source:
|
||||
with sqlite3.connect(f'file:{path}?mode=ro',uri=True) as db:
|
||||
if db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='realm_state'").fetchone():candidates.append(path)
|
||||
if len(candidates)!=1:raise RuntimeError('Expected exactly one live realm database')
|
||||
source=candidates[0];target=stage/source.relative_to(live)
|
||||
if target.exists():raise RuntimeError('Staging database already exists; refusing to overwrite')
|
||||
target.parent.mkdir(parents=True,exist_ok=True)
|
||||
with sqlite3.connect(f'file:{source}?mode=ro',uri=True) as src,sqlite3.connect(target) as dst:
|
||||
src.backup(dst)
|
||||
tables={row[0] for row in dst.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
for table in ['ale_commands','ale_events','community_actions','community_clock','conversation_requests','realm_state_chunks']:
|
||||
if table in tables:dst.execute('DELETE FROM '+table)
|
||||
text=json.dumps(state,ensure_ascii=False,separators=(',',':'))
|
||||
parts=[text[i:i+20000] for i in range(0,len(text),20000)];token=str(uuid.uuid4())
|
||||
dst.execute('CREATE TABLE IF NOT EXISTS realm_state_chunks (token TEXT,part INTEGER,payload TEXT,PRIMARY KEY(token,part))')
|
||||
dst.executemany('INSERT INTO realm_state_chunks VALUES (?,?,?)',[(token,i,p) for i,p in enumerate(parts)])
|
||||
dst.execute("UPDATE realm_state SET revision=?,payload=? WHERE id='realm'",(state['revision'],json.dumps({'chunkToken':token,'parts':len(parts)})))
|
||||
(root/'classic-initial-state.json').write_text(json.dumps(state))
|
||||
print('Isolated dashboard prepared: 50 preserved personalities, native GUIDs rebound, historical memories labeled, live observations and queues reset')
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser();parser.add_argument('--root',type=pathlib.Path,required=True);parser.add_argument('--live',type=pathlib.Path,required=True)
|
||||
args=parser.parse_args();main(args.root.resolve(),args.live.resolve())
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Relay typed Classic commands and current native observations; no Lua execution."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
RACES={1:'Human',2:'Orc',3:'Dwarf',4:'Night Elf',5:'Undead',6:'Tauren',7:'Gnome',8:'Troll'}
|
||||
CLASSES={1:'Warrior',2:'Paladin',3:'Hunter',4:'Rogue',5:'Priest',7:'Shaman',8:'Mage',9:'Warlock',11:'Druid'}
|
||||
|
||||
def validate(command):
|
||||
if not isinstance(command,dict) or command.get('version')!=1 or command.get('backend')!='cmangos-classic': raise ValueError('Not a typed Classic command')
|
||||
if command.get('kind') not in {'party','leave','recruit','intent','say','reply-say','reply-whisper'}:raise ValueError('Unsupported command kind')
|
||||
for key in ['guid','accountId','peerGuid','peerAccountId','expiresAt']:
|
||||
if type(command.get(key)) is not int or command[key]<0:raise ValueError('Invalid '+key)
|
||||
if not command['guid'] or not command['accountId'] or not re.fullmatch('[A-Za-z]{2,12}',command.get('name','')):raise ValueError('Invalid actor identity')
|
||||
if command['peerGuid'] and (not command['peerAccountId'] or not re.fullmatch('[A-Za-z]{2,12}',command.get('peerName',''))):raise ValueError('Invalid peer identity')
|
||||
if command['kind'] in {'party','recruit','say','reply-say','reply-whisper'} and not command['peerGuid']:raise ValueError('Peer identity required')
|
||||
if not isinstance(command.get('argument'),str) or len(command['argument'].encode())>1000:raise ValueError('Invalid argument')
|
||||
if command['expiresAt']<time.time() or command['expiresAt']>time.time()+120:raise ValueError('Expired or invalid deadline')
|
||||
if command['kind']=='intent' and command['argument'] not in {'quest','explore','rest','trade','socialize'}:raise ValueError('Unsupported intention')
|
||||
return command
|
||||
|
||||
def main(root,base,env_file):
|
||||
credentials=json.loads((root/'database-credentials.json').read_text())
|
||||
values={}
|
||||
for line in env_file.read_text().splitlines():
|
||||
if '=' in line and not line.lstrip().startswith('#'):
|
||||
key,value=line.split('=',1);values[key.strip()]=value.strip().strip('\"\'')
|
||||
token=values['ALE_BRIDGE_TOKEN']
|
||||
env=dict(os.environ,MYSQL_PWD=credentials['root'])
|
||||
def mysql(sql):
|
||||
result=subprocess.run(['docker','exec','-i','-e','MYSQL_PWD','azerothai-classic-db','mysql','-uroot','-N','-B','--raw','classiccharacters'],input=sql,text=True,capture_output=True,env=env,timeout=10)
|
||||
if result.returncode:raise RuntimeError('Classic database query failed')
|
||||
return [line.split('\t') for line in result.stdout.strip().splitlines()]
|
||||
def quote(text):return "CONVERT(UNHEX('"+str(text).encode().hex()+"') USING utf8mb4)"
|
||||
def request(path,payload=None):
|
||||
body=json.dumps(payload or {}).encode()
|
||||
req=urllib.request.Request(base.rstrip('/')+path,data=body,headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'})
|
||||
with urllib.request.urlopen(req,timeout=10) as response:return response.headers,response.read().decode()
|
||||
def complete(id,ok,result):request('/api/bridge/ale/result',{'id':id,'ok':ok,'result':result})
|
||||
last_snapshot=0;last_cleanup=0;last_outcomes=0
|
||||
while True:
|
||||
try:
|
||||
now=int(time.time())
|
||||
if now-last_snapshot>=5:
|
||||
rows=mysql('SELECT HEX(payload) FROM azerothai_snapshots WHERE at>=UNIX_TIMESTAMP()-15 ORDER BY guid LIMIT 51')
|
||||
if len(rows)>50:raise RuntimeError('Classic snapshot population exceeds 50')
|
||||
payloads=[]
|
||||
for row in rows:
|
||||
item=json.loads(bytes.fromhex(row[0]))
|
||||
item['raceName']=RACES.get(item.get('race'),'Unknown')
|
||||
item['className']=CLASSES.get(item.get('class'),'Unknown')
|
||||
payloads.append(item)
|
||||
roster={'roster':True,'observedAt':now,'characterGuids':[p['characterGuid'] for p in payloads]}
|
||||
# Rotate eligible nearby pairs; normal app cooldowns bound conversations.
|
||||
pairs=[]
|
||||
available=lambda p:not any(p.get(k) for k in ['humanControlled','isDead','isGhost','inCombat','taxi','casting'])
|
||||
for p in payloads:
|
||||
if not available(p):continue
|
||||
for q in payloads:
|
||||
if p['characterGuid']>=q['characterGuid'] or not available(q):continue
|
||||
if p['team']==q['team'] and p['mapId']==q['mapId'] and (p['x']-q['x'])**2+(p['y']-q['y'])**2<=625:pairs.append((p,q))
|
||||
if pairs:
|
||||
a,b=pairs[(now//10)%len(pairs)];roster.update(socialA=a['name'],socialB=b['name'],zoneName=a.get('zoneName',''))
|
||||
request('/api/bridge/ale/snapshots',{'payloads':payloads+[roster]});last_snapshot=now
|
||||
for event_id,kind,payload in mysql("SELECT id,kind,HEX(payload) FROM azerothai_events WHERE delivered=0 ORDER BY id LIMIT 30"):
|
||||
data=json.loads(bytes.fromhex(payload));data['nativeEventId']='classic:'+event_id
|
||||
request('/api/bridge/ale/event',{'kind':kind,'payload':data})
|
||||
mysql('UPDATE azerothai_events SET delivered=1 WHERE id='+str(int(event_id)))
|
||||
if now-last_outcomes>=60:
|
||||
rows=mysql("SELECT r.guid,r.name,COALESCE(c.quests,0),COALESCE(c.rewarded,0),COALESCE(c.lootEvents,0),COALESCE(c.lootItems,0),COALESCE(c.lootedCopper,0),COALESCE(c.nativeDeaths,0) FROM azerothai_roster r LEFT JOIN azerothai_counters c ON c.guid=r.guid WHERE r.enabled=1")
|
||||
outcomes=[]
|
||||
for guid,name,quests,rewarded,events,items,copper,deaths in rows:
|
||||
outcomes.append(dict(guid=int(guid),name=name,quests=int(quests),rewarded=int(rewarded),lootEvents=int(events),lootItems=int(items),lootedCopper=int(copper),nativeDeaths=int(deaths),abandoned=0,partyKills=0))
|
||||
body=json.dumps(dict(at=now,recoveries=[],outcomes=outcomes)).encode()
|
||||
req=urllib.request.Request(base.rstrip('/')+'/api/realm-insights',data=body,headers={'Authorization':'Bearer '+values['AUTONOMY_TOKEN'],'Content-Type':'application/json'})
|
||||
with urllib.request.urlopen(req,timeout=10) as response:response.read()
|
||||
last_outcomes=now
|
||||
headers,body=request('/api/bridge/ale/poll')
|
||||
id=headers.get('X-AzerothAI-Command')
|
||||
if id:
|
||||
if not re.fullmatch('[0-9a-fA-F-]{36}',id):raise ValueError('Invalid command ID')
|
||||
try:c=validate(json.loads(body))
|
||||
except (ValueError,TypeError) as error:complete(id,False,str(error))
|
||||
else:
|
||||
mysql(f"INSERT IGNORE INTO azerothai_commands(id,guid,account_id,name,kind,argument,peer_guid,peer_account_id,peer_name,created_at,expires_at) VALUES ({quote(id)},{c['guid']},{c['accountId']},{quote(c['name'])},{quote(c['kind'])},{quote(c['argument'])},{c['peerGuid']},{c['peerAccountId']},{quote(c['peerName'])},{c['expiresAt']-90},{c['expiresAt']})")
|
||||
for id,status,result in mysql("SELECT id,status,HEX(COALESCE(result,'')) FROM azerothai_commands WHERE delivered=0 AND status IN ('completed','failed') LIMIT 16"):
|
||||
complete(id,status=='completed',bytes.fromhex(result).decode());mysql('UPDATE azerothai_commands SET delivered=1 WHERE id='+quote(id))
|
||||
mysql("UPDATE azerothai_commands SET status='failed',result='Expired before native execution' WHERE status='pending' AND expires_at<UNIX_TIMESTAMP()")
|
||||
if now-last_cleanup>3600:
|
||||
mysql("DELETE FROM azerothai_events WHERE delivered=1 AND at<UNIX_TIMESTAMP()-604800")
|
||||
mysql("DELETE FROM azerothai_commands WHERE delivered=1 AND created_at<UNIX_TIMESTAMP()-604800");last_cleanup=now
|
||||
except Exception as error:
|
||||
# Do not print request objects, credentials, or native SQL arguments.
|
||||
print(type(error).__name__+': '+str(error).replace(token,'[redacted]').replace(credentials['password'],'[redacted]').replace(credentials['root'],'[redacted]'),flush=True)
|
||||
time.sleep(1)
|
||||
|
||||
if __name__=='__main__':
|
||||
parser=argparse.ArgumentParser();parser.add_argument('--root',type=pathlib.Path,required=True);parser.add_argument('--url',required=True);parser.add_argument('--env-file',type=pathlib.Path,required=True)
|
||||
args=parser.parse_args();main(args.root,args.url,args.env_file)
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Apply only to the isolated Classic character database.
|
||||
CREATE TABLE IF NOT EXISTS azerothai_roster (
|
||||
guid INT UNSIGNED PRIMARY KEY,
|
||||
account_id INT UNSIGNED NOT NULL,
|
||||
name VARCHAR(12) NOT NULL UNIQUE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
plot_armor BOOLEAN NOT NULL DEFAULT FALSE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS azerothai_commands (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
guid INT UNSIGNED NOT NULL,
|
||||
account_id INT UNSIGNED NOT NULL,
|
||||
name VARCHAR(12) NOT NULL,
|
||||
kind VARCHAR(20) NOT NULL,
|
||||
argument TEXT NOT NULL,
|
||||
peer_guid INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
peer_account_id INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
peer_name VARCHAR(12) NOT NULL DEFAULT '',
|
||||
expires_at BIGINT UNSIGNED NOT NULL,
|
||||
created_at BIGINT UNSIGNED NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
result TEXT,
|
||||
delivered BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
INDEX pending (status,created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS azerothai_snapshots (
|
||||
guid INT UNSIGNED PRIMARY KEY,
|
||||
at BIGINT UNSIGNED NOT NULL,
|
||||
payload MEDIUMTEXT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS azerothai_events (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
at BIGINT UNSIGNED NOT NULL,
|
||||
kind VARCHAR(100) NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
delivered BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
INDEX delivery (delivered,id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE IF NOT EXISTS azerothai_counters (
|
||||
guid INT UNSIGNED PRIMARY KEY,
|
||||
quests BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
rewarded BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
lootEvents BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
lootItems BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
lootedCopper BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
nativeDeaths BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
learnedSpells BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
resurrections BIGINT UNSIGNED NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Finish isolated Classic prerequisites. Never starts a realm or edits live data."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--root', required=True)
|
||||
args = parser.parse_args()
|
||||
root = pathlib.Path(args.root).resolve()
|
||||
|
||||
def status(stage, **extra):
|
||||
value = {'at': time.time(), 'stage': stage, 'cutover': False, **extra}
|
||||
temporary = root / 'prerequisites-status.tmp'
|
||||
temporary.write_text(json.dumps(value, indent=2))
|
||||
temporary.replace(root / 'prerequisites-status.json')
|
||||
print(stage, flush=True)
|
||||
|
||||
def run(*command):
|
||||
return subprocess.check_output(command, text=True).strip()
|
||||
|
||||
try:
|
||||
status('waiting for Classic compilation and client transfer')
|
||||
deadline = time.monotonic() + 6 * 3600
|
||||
while True:
|
||||
state = json.loads(run('docker', 'inspect', 'azerothai-classic-build'))[0]['State']
|
||||
if not state['Running']:
|
||||
if state['ExitCode'] != 0:
|
||||
raise RuntimeError('Classic build failed; inspect docker logs azerothai-classic-build')
|
||||
break
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError('Classic build exceeded six-hour observation window')
|
||||
time.sleep(30)
|
||||
manifest = json.loads((root / 'client-manifest.json').read_text())
|
||||
while not all((root / 'client' / f).exists() and (root / 'client' / f).stat().st_size == v['size'] for f, v in manifest.items()):
|
||||
if time.monotonic() > deadline:
|
||||
raise RuntimeError('Client transfer incomplete after six hours')
|
||||
time.sleep(30)
|
||||
status('verifying transferred client archives')
|
||||
for filename, expected in manifest.items():
|
||||
with (root / 'client' / filename).open('rb') as stream:
|
||||
actual = hashlib.file_digest(stream, 'sha256').hexdigest()
|
||||
if actual != expected['sha256']:
|
||||
raise RuntimeError('Client archive checksum mismatch: ' + filename)
|
||||
if shutil.disk_usage(root).free < 8 * 1024**3:
|
||||
raise RuntimeError('Less than 8 GiB free for extracted Vanilla data')
|
||||
output = root / 'extracted'; output.mkdir(exist_ok=True)
|
||||
status('extracting Vanilla maps and navigation data; realm not switched')
|
||||
with (root / 'data-extraction.log').open('w') as log:
|
||||
subprocess.run([
|
||||
'docker', 'run', '--name', 'azerothai-classic-extract', '--cpus', '2', '--memory', '4g',
|
||||
'--user', '1000:1000', '-i', '-v', str(root) + ':/work', '-w', '/work/run/bin/tools',
|
||||
'azerothai-classic-builder:local', 'bash', './ExtractResources.sh', '/work/client', '/work/extracted'
|
||||
], input='y\n2\n\nn\nn\n\n', text=True, stdout=log, stderr=subprocess.STDOUT, check=True)
|
||||
counts = {name: sum(1 for f in (output / name).rglob('*') if f.is_file()) for name in ('dbc', 'maps', 'vmaps', 'mmaps')}
|
||||
if not all(counts.values()):
|
||||
raise RuntimeError('An extracted data directory is empty: ' + str(counts))
|
||||
status('prerequisites extracted; database, connector, roster and gameplay validation remain', files=counts)
|
||||
except Exception as error:
|
||||
status('blocked', error=str(error))
|
||||
raise
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user