28 changed files with 1067 additions and 30 deletions
+57 -4
View File
@@ -112,6 +112,9 @@ local function player_payload(player)
raceName=player:GetRaceAsString() or tostring(player:GetRace()),
className=player:GetClassAsString() or tostring(player:GetClass()),
healthPct=player:GetHealthPct(),
observedAt=os.time(),team=player:GetTeam(),x=player:GetX(),y=player:GetY(),
guildId=player:GetGuildId(),guildName=player:GetGuildName() or "",guildRank=player:GetGuildRank(),
inCombat=player:IsInCombat(),
isBot=isBot
}
end
@@ -138,11 +141,22 @@ local function nearby_bot_names(player)
return table.concat(names,",")
end
local function world_payload(player)
local p=player_payload(player)
local g=player:GetGroup()
p.groupId=g and tostring(g:GetGUID()) or ""
local members={}
if g then for _,m in pairs(g:GetMembers()) do members[#members+1]=m:GetName() end end
p.groupNames=table.concat(members,",")
p.nearbyBots=nearby_bot_names(player)
return p
end
function AzerothAIEmitPlayerSnapshot(name)
if not name or name == "" then return 0 end
local ok,player=pcall(function() return GetPlayerByName(name) end)
if not ok or not player or not is_playerbot(player) then return 0 end
emit("player.snapshot",player_payload(player))
emit("player.snapshot",world_payload(player))
return 1
end
@@ -196,7 +210,6 @@ function AzerothAIEmitSnapshots()
local guids={}
for _,player in pairs(players) do
if is_playerbot(player) then
emit("player.snapshot",player_payload(player))
guids[#guids+1]=player:GetGUIDLow()
emitted=emitted+1
end
@@ -207,9 +220,9 @@ function AzerothAIEmitSnapshots()
for _,p in pairs(players) do if is_playerbot(p) then ordered[#ordered+1]=p end end
table.sort(ordered,function(a,b) return (socialLast[a:GetGUIDLow()] or 0)<(socialLast[b:GetGUIDLow()] or 0) end)
for _,p in ipairs(ordered) do
if not p:IsDead() and not p:IsInCombat() and os.time()-(socialLast[p:GetGUIDLow()] or 0)>600 then
if not p:IsDead() and not p:IsInCombat() and not p:IsMoving() and os.time()-(socialLast[p:GetGUIDLow()] or 0)>600 then
for _,q in pairs(p:GetPlayersInRange(25) or {}) do
if q:GetGUIDLow()~=p:GetGUIDLow() and is_playerbot(q) and not q:IsDead() and not q:IsInCombat() and p:GetTeam()==q:GetTeam() and p:GetPhaseMask()==q:GetPhaseMask() and os.time()-(socialLast[q:GetGUIDLow()] or 0)>600 then
if q:GetGUIDLow()~=p:GetGUIDLow() and is_playerbot(q) and not q:IsDead() and not q:IsInCombat() and not q:IsMoving() and p:GetDistance(q)<=15 and p:GetTeam()==q:GetTeam() and p:GetPhaseMask()==q:GetPhaseMask() and os.time()-(socialLast[q:GetGUIDLow()] or 0)>600 then
roster.socialA=p:GetName();roster.socialB=q:GetName();roster.zoneName=area_name(p:GetZoneId())
socialLast[p:GetGUIDLow()]=os.time();socialLast[q:GetGUIDLow()]=os.time()
break
@@ -219,6 +232,9 @@ function AzerothAIEmitSnapshots()
if roster.socialA then break end
end
emit("player.snapshot",roster)
for _,player in pairs(players) do
if is_playerbot(player) then emit("player.snapshot",world_payload(player)) end
end
return emitted
end
@@ -380,3 +396,40 @@ end
emit("bridge.ready",{pollMs=POLL_MS,protectedCharacter=PROTECTED})
PrintInfo("[AzerothAI] ALE bridge loaded; durable DB command polling every " .. tostring(POLL_MS) .. " ms")
local completedQuests={}
local helpedAt={}
local function cooperative_event(a,b,kind,key,quest)
emit("player.snapshot",{cooperation=kind,evidenceId=key,characterGuid=a:GetGUIDLow(),peerGuid=b:GetGUIDLow(),observedAt=os.time(),questId=quest or 0})
end
RegisterPlayerEvent(54,function(event,player,quest)
if not is_playerbot(player) then return end
local q=quest:GetId()
completedQuests[player:GetGUIDLow()..":"..q]=os.time()
local g=player:GetGroup();if not g then return end
for _,other in pairs(g:GetMembers()) do
if other~=player and is_playerbot(other) and player:GetDistance(other)<=35 then
local stamp=completedQuests[other:GetGUIDLow()..":"..q]
if stamp and os.time()-stamp<300 then
local a,b=player:GetGUIDLow(),other:GetGUIDLow()
cooperative_event(player,other,"quest","quest:"..math.min(a,b)..":"..math.max(a,b)..":"..q..":"..math.floor(os.time()/86400),q)
end
end
end
end)
RegisterPlayerEvent(7,function(event,player,killed)
if not is_playerbot(player) then return end
local g=player:GetGroup();if not g then return end
for _,other in pairs(g:GetMembers()) do
if other~=player and is_playerbot(other) and not other:IsDead() and other:IsInCombat() and other:GetVictim() and other:GetVictim():GetGUID()==killed:GetGUID() and player:GetDistance(other)<=35 then
local a,b=player:GetGUIDLow(),other:GetGUIDLow();local key=math.min(a,b)..":"..math.max(a,b)
if os.time()-(helpedAt[key] or 0)>300 then
helpedAt[key]=os.time();cooperative_event(player,other,"assist","assist:"..key..":"..os.time())
end
end
end
end)
CreateLuaEvent(function()
for k,t in pairs(completedQuests) do if os.time()-t>300 then completedQuests[k]=nil end end
for k,t in pairs(helpedAt) do if os.time()-t>600 then helpedAt[k]=nil end end
end,60000,0)
+12
View File
@@ -9,6 +9,7 @@ local TARGET_PER_FACTION = 25
local gates = {}
local gateAt = {}
local arrivals = {}
local reconciledAt = 0
local alliance = {[1]=true,[3]=true,[4]=true,[7]=true,[11]=true}
local horde = {[2]=true,[5]=true,[6]=true,[8]=true,[10]=true}
local function faction(row)
@@ -77,6 +78,17 @@ end
local function enforce()
if not ready then reload() end
-- The dashboard may receive a verified death from an older transport backlog
-- after this process seeded its registry. Import terminal states only; never
-- overwrite a locally observed death with an older active database row.
if os.time()-reconciledAt>=10 then
local q=AuthDBQuery("SELECT guid,name,account_id FROM azerothai_lifecycle WHERE state='dead' AND protected=0 AND LOWER(name)<>'zaku'")
if q then repeat
local row=residents[q:GetUInt32(0)]
if row and not row.protected and row.name==q:GetString(1) and row.account==q:GetUInt32(2) then row.state="dead" end
until not q:NextRow() end
reconciledAt=os.time()
end
local mgr=GetRandomPlayerbotMgr()
-- Disallow every retired identity before the native manager fills vacancies.
for guid,row in pairs(residents) do
+25
View File
@@ -0,0 +1,25 @@
import {env} from 'cloudflare:workers';
import {z} from 'zod';
import {D1Store} from '@/core/store';
import type {RuntimeConfig} from '@/core/types';
const sample=z.object({host:z.enum(['ubuntu','windows']),at:z.number().finite(),cpuPercent:z.number().min(0).max(100),memoryTotalMB:z.number().nonnegative(),memoryAvailableMB:z.number().nonnegative(),diskFreeGB:z.number().nonnegative(),databaseMB:z.number().nonnegative().optional(),logsMB:z.number().nonnegative().optional(),modelAvailable:z.boolean().optional(),runtimeService:z.string().max(40).optional(),maintenance:z.string().max(500).optional()}).strict();
async function ensure(){await env.DB!.prepare('CREATE TABLE IF NOT EXISTS operations_samples (host TEXT PRIMARY KEY,at INTEGER,payload TEXT)').run();}
export async function GET(){
await ensure();const rows=await env.DB!.prepare('SELECT payload FROM operations_samples').all<{payload:string}>();
const s=await new D1Store().load(),now=Date.now()/1000;
const stuck=s.bots.filter(b=>b.status==='online'&&b.world&&now-b.world.at<120&&!b.world.inCombat&&b.world.stationarySince&&now-b.world.stationarySince>600&&['quest','explore'].includes(b.lastDecision?.action??'')).map(b=>({name:b.name,minutes:Math.floor((now-b.world!.stationarySince!)/60),zone:b.zone}));
return Response.json({samples:rows.results.map(r=>JSON.parse(r.payload)),stuck,retentionDays:s.opsRetentionDays??7,rosterAt:s.aleRosterAt??null},{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});
try{
const raw=await request.text();if(raw.length>4000)throw new Error('Sample too large');const body=sample.parse(JSON.parse(raw));
if(Math.abs(Date.now()/1000-body.at)>180)throw new Error('Stale telemetry');
await ensure();await env.DB!.prepare('INSERT INTO operations_samples VALUES (?,?,?) ON CONFLICT(host) DO UPDATE SET at=excluded.at,payload=excluded.payload WHERE excluded.at>operations_samples.at').bind(body.host,body.at,JSON.stringify(body)).run();
// Only terminal commands are eligible; awaiting receipts and character state survive.
const s=await new D1Store().load();const before=new Date(Date.now()-(s.opsRetentionDays??7)*86400_000).toISOString();
if(body.host==='ubuntu')await env.DB!.prepare("DELETE FROM ale_commands WHERE status IN ('completed','failed') AND completed_at<? AND id NOT IN (SELECT command_id FROM community_actions WHERE status='pending') AND NOT EXISTS (SELECT 1 FROM conversation_requests c WHERE c.status='awaiting_delivery' AND instr(c.result,ale_commands.id)>0)").bind(before).run();
return Response.json({ok:true});
}catch(e){return Response.json({error:e instanceof Error?e.message:'Invalid telemetry'},{status:400});}
}
+23
View File
@@ -0,0 +1,23 @@
'use client';
import {useEffect,useState} from 'react';
import type {State} from '@/core/types';
import {Button} from '@/components/ui/button';
export function CommunityPanel({state,act,busy}:{state:State;act:(a:any)=>Promise<any>;busy:boolean}){
const [filter,setFilter]=useState('all');const c=state.community;
const parties=Object.entries(state.bots.filter(b=>b.status==='online'&&b.world?.groupId).reduce<Record<string,State['bots']>>((groups,b)=>{(groups[b.world!.groupId]??=[]).push(b);return groups;},{}));
return <>
<section className="panel"><div className="panel-head"><h2>Cooperative play</h2></div><div className="panel-body"><p>Maximum 50 residents. Group and guild changes appear as confirmed only after the game accepts them.</p><div className="actions mt-5">
<Button disabled={busy} variant="outline" onClick={()=>act({type:'communityPolicy',partiesEnabled:!(c?.partiesEnabled??true),guildsEnabled:c?.guildsEnabled??true})}>Automatic parties: {c?.partiesEnabled===false?'off':'on'}</Button>
<Button disabled={busy} variant="outline" onClick={()=>act({type:'communityPolicy',partiesEnabled:c?.partiesEnabled??true,guildsEnabled:!(c?.guildsEnabled??true)})}>Guild recruitment: {c?.guildsEnabled===false?'off':'on'}</Button>
</div></div></section>
<div className="section-grid mt-5"><section className="panel"><div className="panel-head"><h2>Live parties</h2></div><div className="panel-body">{parties.length?parties.map(([id,bots])=><div className="memory" key={id}><strong>{bots?.map(b=>b.name).join(', ')}</strong><p>{bots?.[0]?.zone} · {bots?.length} observed members</p></div>):<p>No parties currently observed.</p>}</div></section>
<section className="panel"><div className="panel-head"><h2>Guild goals</h2></div><div className="panel-body">{c?.guilds.length?c.guilds.map(g=><div className="memory" key={g.id}><strong>{g.name}</strong><p>{g.progress} confirmed contributions · milestone {g.target}</p><label>Shared goal <select aria-label={`${g.name} goal`} value={g.goal} disabled={busy} onChange={e=>act({type:'guildGoal',guildId:g.id,goal:e.target.value})}><option value="quest">Complete quests together</option><option value="support">Help guildmates in combat</option><option value="explore">Explore other maps</option></select></label></div>):<p>Waiting for guild observations.</p>}</div></section></div>
<section className="panel mt-5"><div className="panel-head"><h2>Social activity</h2><select aria-label="Social activity filter" value={filter} onChange={e=>setFilter(e.target.value)}>{['all','conversation','relationship','invitation','party','leave','recruit','guild'].map(v=><option key={v}>{v}</option>)}</select></div><div className="panel-body">{[...(c?.feed??[])].reverse().filter(e=>filter==='all'||e.kind===filter).map(e=><article className="memory" key={e.id}><small>{new Date(e.at).toLocaleString()} · {e.kind} · {e.status??'observed'}</small><h3>{e.names.join(' ↔ ')}</h3><p style={{whiteSpace:'pre-wrap'}}>{e.text}</p>{e.affinityDelta!==undefined&&<small>Trust change: {e.affinityDelta>0?'+':''}{e.affinityDelta}</small>}</article>)}{!c?.feed.length&&<p>No confirmed social activity recorded yet.</p>}</div></section>
</>;
}
export function OperationsPanel({state,act,busy}:{state:State;act:(a:any)=>Promise<any>;busy:boolean}){
const [data,setData]=useState<any>(null),[error,setError]=useState('');
useEffect(()=>{let alive=true;const refresh=async()=>{try{const r=await fetch('/api/operations');if(!r.ok)throw new Error('Health data unavailable');const d=await r.json();if(alive){setData(d);setError('');}}catch(e){if(alive)setError(String(e));}};void refresh();const timer=setInterval(refresh,10000);return()=>{alive=false;clearInterval(timer);};},[]);
return <><div className="section-grid">{['ubuntu','windows'].map(host=>{const s=data?.samples.find((s:any)=>s.host===host),stale=!s||Date.now()/1000-s.at>180;return <section className="panel" key={host}><div className="panel-head"><h2>{host==='ubuntu'?'Ubuntu server':'Windows PC'}</h2></div><div className="panel-body">{s?<><p>{stale?'Telemetry stale — values may have changed':'Current telemetry'} · {new Date(s.at*1000).toLocaleTimeString()}</p>{[['CPU',s.cpuPercent.toFixed(1)+'%'],['Available RAM',Math.round(s.memoryAvailableMB)+' / '+Math.round(s.memoryTotalMB)+' MB'],['Free disk',s.diskFreeGB.toFixed(1)+' GB'],...(s.databaseMB!==undefined?[['Application database',s.databaseMB.toFixed(1)+' MB'],['Logs',s.logsMB.toFixed(1)+' MB'],['Model',s.modelAvailable?'Available':'Unavailable'],['Runtime recovery',s.runtimeService]]:[])].map(([label,value]:any)=><div className="health-row" key={label}><span>{label}</span><strong>{value}</strong></div>)}{s.memoryAvailableMB<1024&&<p className="notice">Memory pressure: keep the population capped at 50.</p>}<p className="fineprint">{s.maintenance}</p></>:<p>Waiting for telemetry from this machine.</p>}</div></section>;})}</div>
<section className="panel mt-5"><div className="panel-head"><h2>Storage and stalled activity</h2></div><div className="panel-body">{error&&<p role="alert">{error}</p>}<label>Keep application command history for <select aria-label="History retention" disabled={busy} value={state.opsRetentionDays??7} onChange={e=>act({type:'retentionPolicy',days:Number(e.target.value)})}>{[3,7,14,30].map(n=><option value={n} key={n}>{n} days</option>)}</select></label><p className="fineprint">Memories, relationships and memorials are preserved. Acknowledged game transport expires after one day; archived snapshots use the selected retention. Logs rotate at 10 MB with three archives. The social feed holds 250 entries.</p><h3>Possible stalled bots</h3><p className="fineprint">Stationary for over 10 minutes while intending to quest or explore. This is a review signal, not proof of being stuck; no automatic teleporting.</p>{data?.stuck?.length?data.stuck.map((b:any)=><p key={b.name}>{b.name} · {b.zone} · {b.minutes} minutes</p>):<p>No candidates detected.</p>}</div></section></>;
}
+2
View File
File diff suppressed because one or more lines are too long
+21 -17
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
'use client';
import Home from '../page';
export default function Visitor(){return <Home readOnly/>;}
+2
View File
@@ -141,6 +141,8 @@ export async function runLiveAutonomyTick(
let bot=pick.bot;
const observation=observationFrom(bot,pick.obs,events);
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 brain=providerForBot(bot,s.settings,config);
let decision:Decision;
let usedFallback=false;
+118
View File
@@ -0,0 +1,118 @@
import type {Bot,State,StateStore,SocialEntry} from './types';
import {luaString} from './ale';
import {queueAleCommand} from './ale-store';
export function community(s:State){return s.community??(s.community={feed:[],evidence:[],guilds:[],partiesEnabled:true,guildsEnabled:true});}
export function socialEntry(s:State,e:SocialEntry){const c=community(s);if(!c.feed.some(x=>x.id===e.id))c.feed.push(e);c.feed=c.feed.slice(-250);}
export function bondEvidence(s:State,id:string,a:Bot,b:Bot,reason:string,delta:number){
const c=community(s);if(c.evidence.includes(id)||a.id===b.id)return false;
c.evidence.push(id);c.evidence=c.evidence.slice(-1000);
for(const [from,to] of [[a,b],[b,a]]){
let r=from.relationships.find(x=>x.targetId===to.id);
if(!r){r={targetId:to.id,affinity:0,note:reason};from.relationships.push(r);}
r.affinity=Math.max(-100,Math.min(100,r.affinity+delta));r.note=reason;
from.relationships=from.relationships.slice(-100);
}
socialEntry(s,{id,at:new Date().toISOString(),kind:'relationship',names:[a.name,b.name],text:reason,affinityDelta:delta,status:'confirmed'});
return true;
}
export async function changeCommunity(store:StateStore,fn:(s:State)=>void){
for(let n=0;n<4;n++){const s=await store.load(),r=s.revision;fn(s);try{await store.save(s,r);return;}catch(e){if(n===3||!(e instanceof Error)||!e.message.includes('State changed'))throw e;}}
}
export function observePresence(s:State,b:Bot,p:Record<string,unknown>){
const at=Number(p.observedAt);if(!Number.isFinite(at)||at<=(b.world?.at??0)||Math.abs(Date.now()/1000-at)>120)return;
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={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};
const c=community(s);
if(b.world.guildId&&!c.guilds.some(g=>g.id===b.world!.guildId))c.guilds.push({id:b.world.guildId,name:b.world.guildName,goal:['quest','support','explore'][b.world.guildId%3] as 'quest'|'support'|'explore',progress:0,target:20});
if(old&&old.mapId!==b.world.mapId&&b.world.guildId){
const g=c.guilds.find(g=>g.id===b.world!.guildId),key=`explore:${b.id}:${b.world.mapId}:${Math.floor(at/86400)}`;
if(g?.goal==='explore'&&!c.evidence.includes(key)){g.progress++;c.evidence.push(key);c.evidence=c.evidence.slice(-1000);socialEntry(s,{id:key,at:new Date().toISOString(),kind:'guild',names:[b.name,g.name],text:'Reached another map for the guild exploration goal.',status:'confirmed'});}
}
}
export function applyCooperation(s:State,p:Record<string,unknown>){
const at=Number(p.observedAt);if(!Number.isFinite(at)||Math.abs(Date.now()/1000-at)>120)return;
const id=String(p.evidenceId||'');if(!id||community(s).evidence.includes(id))return;
const a=s.bots.find(b=>b.autonomy?.characterGuid===p.characterGuid),b=s.bots.find(b=>b.autonomy?.characterGuid===p.peerGuid);
if(!a||!b||a.status==='dead'||b.status==='dead')return;
const kind=p.cooperation;
if(kind!=='quest'&&kind!=='assist')return;
if(bondEvidence(s,id,a,b,kind==='quest'?'Completed the same quest while adventuring together.':'Helped defeat an enemy together.',kind==='quest'?3:1)){
for(const bot of [a,b]){bot.memories.push({id,text:kind==='quest'?`Completed quest ${String(p.questId)} with ${bot.id===a.id?b.name:a.name}.`:`Fought alongside ${bot.id===a.id?b.name:a.name}.`,importance:6,source:'observation',at:new Date().toISOString()});bot.memories=bot.memories.slice(-100);}
if(a.world?.guildId&&a.world.guildId===b.world?.guildId){const g=community(s).guilds.find(g=>g.id===a.world!.guildId);if(g&&((g.goal==='quest'&&kind==='quest')||(g.goal==='support'&&kind==='assist')))g.progress++;}
}
}
const affinity=(a:Bot,b:Bot)=>a.relationships.find(r=>r.targetId===b.id)?.affinity??0;
const fresh=(b:Bot)=>b.status==='online'&&b.autonomy?.managed&&b.world&&Date.now()/1000-b.world.at<90&&!b.world.inCombat;
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)>10||!a.world!.nearby.includes(b.name)||!b.world!.nearby.includes(a.name))return {accepted:false,reason:'No compatible nearby pair'};
const willing=(x:Bot,y:Bot)=>affinity(x,y)>=0&&(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)?'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 compileCommunityAction(kind:'party'|'leave'|'recruit',a:Bot,b?:Bot){
const lines=[`local a=GetPlayerByName(${luaString(a.name)})`,`if not a or a:GetGUIDLow()~=${a.autonomy!.characterGuid} or not a:IsPlayerbot() or a:IsDead() or a:IsInCombat() then error('Resident unavailable') end`];
if(kind==='leave'){
lines.push(`local g=a:GetGroup();if not g then return 'Already solo' end`,`if tostring(g:GetGUID())~=${luaString(a.world!.groupId)} then error('Party changed') end`,`if #g:GetMembers()~=g:GetMembersCount() then error('Party has offline members') end;for _,m in pairs(g:GetMembers()) do if not m:IsPlayerbot() then error('Human party is protected') end end`,`a:RemoveFromGroup()`,`if a:GetGroup() then error('Departure was not applied') end`);
}else{
if(!b)throw new Error('Peer required');
lines.push(`local b=GetPlayerByName(${luaString(b.name)})`,`if not b or b:GetGUIDLow()~=${b.autonomy!.characterGuid} or not b:IsPlayerbot() or b:IsDead() or b:IsInCombat() or a:GetTeam()~=b:GetTeam() or a:GetMapId()~=b:GetMapId() or a:GetPhaseMask()~=b:GetPhaseMask() or a:GetDistance(b)>35 then error('Peer unavailable nearby') end`);
if(kind==='party')lines.push(`if math.abs(a:GetLevel()-b:GetLevel())>10 then error('Level mismatch') end`,`local g=a:GetGroup();if g and b:GetGroup()==g then return 'Already grouped' end`,`if b:GetGroup() then error('Peer already grouped') end`,`if g then if g:IsRaidGroup() or g:GetMembersCount()>=5 then error('Party full') end;if #g:GetMembers()~=g:GetMembersCount() then error('Party has offline members') end;for _,m in pairs(g:GetMembers()) do if not m:IsPlayerbot() then error('Human party is protected') end end;if not g:AddMember(b) then error('Party join failed') end else g=a:GroupCreate(b) end`,`if not g or b:GetGroup()~=g then error('Party membership was not applied') end`);
else lines.push(`local g=a:GetGuild();if not g or a:GetGuildId()~=${a.world!.guildId} or a:GetGuildRank()~=0 then error('Guild leader unavailable') end`,`if b:GetGuildId()==a:GetGuildId() then return 'Already a member' end`,`if b:GetGuildId()~=0 then error('Already belongs to a guild') end`,`g:AddMember(b)`,`if b:GetGuildId()~=a:GetGuildId() then error('Guild admission failed') end`);
}
lines.push('AzerothAIEmitSnapshots()','return "Confirmed '+kind+'"');return lines.join('; ');
}
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){
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){
const receipt=await db.prepare('SELECT status,error FROM ale_commands WHERE id=?').bind(action.command_id).first<any>();
if(!receipt||!['completed','failed'].includes(receipt.status))continue;
await changeCommunity(store,s=>{
const a=s.bots.find(b=>b.id===action.a),b=s.bots.find(b=>b.id===action.b);if(!a)return;
const proposal=community(s).feed.find(e=>e.id===action.id+':proposal');if(proposal){proposal.status=receipt.status==='completed'?'confirmed':'failed';proposal.text=action.reason;}
socialEntry(s,{id:action.id,at:new Date().toISOString(),kind:action.kind,names:[a.name,...(b?[b.name]:[])],text:receipt.status==='completed'?action.reason:String(receipt.error||'Game rejected action').replace(/^.*:\d+:\s*/,''),status:receipt.status==='completed'?'confirmed':'failed'});
if(receipt.status==='completed'&&b&&action.kind==='party'&&bondEvidence(s,action.id+':bond',a,b,'Accepted an invitation to adventure together.',1)){
for(const [from,to] of [[a,b],[b,a]]){from.memories.push({id:action.id,text:'Joined a party with '+to.name+' to adventure together.',importance:6,source:'observation',at:new Date().toISOString()});from.memories=from.memories.slice(-100);}
}
});
await db.prepare('UPDATE community_actions SET status=? WHERE id=?').bind(receipt.status,action.id).run();
}
const now=Date.now();
await db.prepare("INSERT OR IGNORE INTO community_clock VALUES ('tick',0)").run();
const gate=await db.prepare("UPDATE community_clock SET at=? WHERE id='tick' AND at<?").bind(now,now-30_000).run();if(gate.meta.changes!==1)return;
await db.prepare("DELETE FROM community_actions WHERE status!='pending' AND created_at<?").bind(new Date(now-7*86400_000).toISOString()).run();
const s=await store.load(),c=community(s),bots=s.bots.filter(b=>fresh(b));
const recent=await db.prepare('SELECT a,b FROM community_actions WHERE created_at>?').bind(new Date(now-10*60_000).toISOString()).all<any>();
const busy=new Set(recent.results.flatMap(r=>[r.a,r.b]));
for(const a of bots){
if(busy.has(a.id))continue;
// Leave only a bot-only party whose peers have moved away, never a human party.
const far=a.world!.groupId&&a.world!.groupNames.length>1&&a.world!.groupNames.filter(n=>n!==a.name).every(n=>!a.world!.nearby.includes(n));
if(c.partiesEnabled&&far){
await db.prepare('INSERT OR IGNORE INTO community_clock VALUES (?,?)').bind('apart:'+a.id,now).run();
const apart=await db.prepare('SELECT at FROM community_clock WHERE id=?').bind('apart:'+a.id).first<{at:number}>();
if(apart&&now-apart.at>10*60_000){await propose('leave',a,undefined,'Left the party after ten minutes apart.');return;}
}else await db.prepare('DELETE FROM community_clock WHERE id=?').bind('apart:'+a.id).run();
for(const b of bots){
if(busy.has(b.id)||a.id===b.id||!a.world!.nearby.includes(b.name))continue;
if(c.guildsEnabled&&a.world!.guildId&&a.world!.guildRank===0&&!b.world!.guildId&&affinity(a,b)>=3&&affinity(b,a)>=3&&partyDecision(a,b).accepted){await propose('recruit',a,b,`Accepted an invitation to ${a.world!.guildName} after building mutual trust.`);return;}
if(c.partiesEnabled&&!b.world!.groupId&&(!a.world!.groupId||a.world!.groupNames.length<5)){
const decision=partyDecision(a,b);
if(!decision.accepted){if(decision.reason==='No compatible nearby pair')continue;await db.prepare('INSERT INTO community_actions VALUES (?,?,?,?,?,?,?,?)').bind(crypto.randomUUID(),new Date().toISOString(),'declined',a.id,b.id,'','declined',decision.reason).run();await changeCommunity(store,s=>socialEntry(s,{id:crypto.randomUUID(),at:new Date().toISOString(),kind:'invitation',names:[a.name,b.name],text:decision.reason,status:'declined'}));return;}
await propose('party',a,b,decision.reason);return;
}
}
}
async function propose(kind:'party'|'leave'|'recruit',a:Bot,b:Bot|undefined,reason:string){
const command=await queueAleCommand(db,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'}));
}
}
+4 -6
View File
@@ -1,3 +1,4 @@
import {bondEvidence,socialEntry,communityTick} from './community';
import { z } from 'zod';
import { queueAleCommand } from './ale-store';
import { luaString } from './ale';
@@ -145,14 +146,10 @@ async function remember(store:StateStore,botId:string,request:ConversationReques
if(peer){
peer.memories.push({id:request.id,text:`I said to ${bot.name}: "${request.message.slice(0,180)}" They replied: "${reply.slice(0,180)}"`,importance:6,source:'observation',at:new Date().toISOString()});
peer.memories=peer.memories.slice(-100);
for(const [from,to] of [[bot,peer],[peer,bot]]){
const existing=from.relationships.find(r=>r.targetId===to.id);
if(existing)existing.note='We have spoken together in person.';
else from.relationships.push({targetId:to.id,affinity:0,note:'We have spoken together in person.'});
from.relationships=from.relationships.slice(-100);
}
bondEvidence(state,request.id+':bond',bot,peer,'Shared a confirmed conversation.',1);
}
}
socialEntry(state,{id:request.id,at:new Date().toISOString(),kind:'conversation',names:[request.speakerName,bot.name],text:`${request.speakerName}: ${request.message}\n${bot.name}: ${reply}`,status:'confirmed'});
bot.simulationBand='foreground';
realmEvent(state,'memory',`${bot.name} had a live conversation with ${request.speakerName}.`,bot.id);
try{
@@ -177,6 +174,7 @@ export async function runConversationTick(store:StateStore,db:D1Database,config:
}
}
await cleanupConversations(db);
await communityTick(store,db);
const request=await leaseConversation(db);
if(!request)return {ok:true,ran:false,reason:'No player conversation waiting'};
+10
View File
@@ -1,3 +1,4 @@
import {community,observePresence,applyCooperation,socialEntry} from './community';
import { runGM } from './gm';
import { actionSchema } from './validation';
import { connectors } from './connectors';
@@ -72,6 +73,7 @@ function observedHardcoreDeath(s:State,b:Bot,observation:Observation){
export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,payload:Record<string,unknown>){
let s=await store.load();
const revision=s.revision;
if(kind==='player.snapshot'&&payload.cooperation){applyCooperation(s,payload);await store.save(s,revision);return {matched:true,cooperation:true};}
// Complete world snapshots own presence once available; historical events
// must never bring disconnected residents back online.
if(kind==='player.snapshot' && payload.roster===true){
@@ -151,6 +153,7 @@ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,pa
}
if(b.status==='retired')return {matched:true,botId:b.id,retired:true};
if(kind==='player.snapshot')observePresence(s,b,payload);
b.level=level;
if(zone)b.zone=zone;
@@ -231,6 +234,13 @@ export async function execute(store:StateStore,input:unknown,config:RuntimeConfi
if(a.type==='gm'){
const id=crypto.randomUUID();s.operations[id]={started:Date.now(),botId:'gm'};event(s,'gm',`${a.live?'LIVE':'SIMULATED'} command requested: ${a.command}`);await store.save(s,revision);
try{const result=await runGM(a.command,a.live,a.confirmed,config);delete s.operations[id];event(s,'gm',`${a.live?'LIVE':'SIMULATED'} ${result.command}: ${result.output}`);await store.save(s,s.revision);return result;}catch(e){delete s.operations[id];event(s,'error',`GM command failed: ${e instanceof Error?e.message:'Unknown error'}`);await store.save(s,s.revision);throw e;}
}else if(a.type==='retentionPolicy'){
s.opsRetentionDays=a.days;
}else if(a.type==='communityPolicy'){
Object.assign(community(s),{partiesEnabled:a.partiesEnabled,guildsEnabled:a.guildsEnabled});
}else if(a.type==='guildGoal'){
const g=community(s).guilds.find(g=>g.id===a.guildId);if(!g)throw new Error('Guild not observed');g.goal=a.goal;g.progress=0;
socialEntry(s,{id:crypto.randomUUID(),at:new Date().toISOString(),kind:'guild',names:[g.name],text:'Guild goal changed to '+a.goal,status:'confirmed'});
}else if(a.type==='director'){
s.director={experience:a.experience,zone:a.zone,playerName:a.playerName,brief:a.brief};
for(const b of s.bots.filter(b=>b.status!=='retired'&&b.status!=='dead')) b.goal=`${a.experience} in ${a.zone}${a.playerName?' around '+a.playerName:''}: ${a.brief}`.slice(0,200);
+6 -2
View File
@@ -10,7 +10,11 @@ export type DeathRecord = {id:string; at:string; zone:string; level:number; caus
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 Bot = {id:string; name:string; role:string; race:string; level:number; zone:string; status:Status; tier:AgentTier; mortal:boolean; plotArmorHpFloor:number; simulationBand:SimulationBand; homeId?:string; routine:Routine; autonomy?:AutonomyState; recipeId:string; recipeVersion:number; personality:Recipe; goal:string; memories:Memory[]; relationships:Relationship[]; decisions:number; death?:DeathRecord; lastDecision?:Decision};
export type WorldPresence = {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};
export type Bot = {world?:WorldPresence;id:string; name:string; role:string; race:string; level:number; zone:string; status:Status; tier:AgentTier; mortal:boolean; plotArmorHpFloor:number; simulationBand:SimulationBand; homeId?:string; routine:Routine; autonomy?:AutonomyState; recipeId:string; recipeVersion:number; personality:Recipe; goal:string; memories:Memory[]; relationships:Relationship[]; decisions:number; death?:DeathRecord; lastDecision?:Decision};
export type Decision = {action:Activity; reason:string; goal:string};
export type RealmEvent = {id:string; seq:number; at:string; kind:'system'|'lifecycle'|'decision'|'memory'|'relationship'|'recipe'|'gm'|'death'|'housing'|'population'|'error'; message:string; botId?:string; mode:'simulation'|'azerothcore'};
export type HardcoreSettings = {enabled:boolean; requireVerifiedDeath:boolean; memorialize:boolean};
@@ -18,7 +22,7 @@ export type PopulationSettings = {defaultZoneTarget:number; zoneMax:number; serv
export type BrainRouterSettings = {enabled:boolean; residentModel:string; heroModel:string; backgroundUseLLM:boolean; residentMemoryLimit:number; heroMemoryLimit:number; residentMaxTokens:number; heroMaxTokens:number};
export type WorldTrigger = {id:string; kind:'zone-change'|'interaction'|'time'|'manual'; zone:string; at:string; playerName?:string; detail?:string};
export type PopulationPlan = {id:string; trigger:WorldTrigger; target:number; activeBefore:number; requested:number; capacity:number; selectedBotIds:string[]; rejectedReasons:string[]; createdAt:string};
export type State = {revision:number; aleRosterAt?:number; bots:Bot[]; homes:Home[]; recipes:Recipe[]; events:RealmEvent[]; sequence:number; settings:{connector:'simulation'|'azerothcore'; provider:'deterministic'|'ollama'|'openai-compatible'; model:string; brains:BrainRouterSettings; hardcore:HardcoreSettings; population:PopulationSettings}; director:{experience:string; zone:string; playerName:string; brief:string}; population:{lastPlan?:PopulationPlan}; operations:Record<string,{started:number; botId:string}>};
export type State = {revision:number; aleRosterAt?:number; community?:Community; opsRetentionDays?:number; bots:Bot[]; homes:Home[]; recipes:Recipe[]; events:RealmEvent[]; sequence:number; settings:{connector:'simulation'|'azerothcore'; provider:'deterministic'|'ollama'|'openai-compatible'; model:string; brains:BrainRouterSettings; hardcore:HardcoreSettings; population:PopulationSettings}; director:{experience:string; zone:string; playerName:string; brief:string}; population:{lastPlan?:PopulationPlan}; operations:Record<string,{started:number; botId:string}>};
export type DeathSignal = {id:string; at:string; cause:string; killer?:string; zone?:string; verified:boolean};
export type Observation = {zone:string; level:number; availableActions:Activity[]; summary:string; alive:boolean; death?:DeathSignal};
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}>;}
+3
View File
@@ -5,6 +5,9 @@ export const recipeSchema=z.object({id:z.string().max(80).optional(),name:z.stri
const populationSchema=z.object({defaultZoneTarget:z.number().int().min(0).max(100),zoneMax:z.number().int().min(1).max(200),serverMax:z.number().int().min(1).max(1000),foregroundMax:z.number().int().min(1).max(200),backgroundMax:z.number().int().min(1).max(1000)});
const brainsSchema=z.object({enabled:z.boolean(),residentModel:z.string().trim().min(1).max(100),heroModel:z.string().trim().min(1).max(100),backgroundUseLLM:z.boolean(),residentMemoryLimit:z.number().int().min(1).max(50),heroMemoryLimit:z.number().int().min(1).max(100),residentMaxTokens:z.number().int().min(64).max(2048),heroMaxTokens:z.number().int().min(64).max(4096)});
export const actionSchema=z.discriminatedUnion('type',[
z.object({type:z.literal('retentionPolicy'),days:z.number().int().min(3).max(30)}),
z.object({type:z.literal('communityPolicy'),partiesEnabled:z.boolean(),guildsEnabled:z.boolean()}),
z.object({type:z.literal('guildGoal'),guildId:z.number().int().positive(),goal:z.enum(['quest','support','explore'])}),
z.object({type:z.literal('createBot'),name:z.string().trim().regex(/^[A-Za-z][A-Za-z '-]{1,23}$/),role:z.enum(['Warrior','Priest','Mage','Rogue','Hunter','Paladin','Warlock','Druid','Shaman']),race:z.enum(['Human','Night Elf','Dwarf','Orc','Undead','Tauren','Gnome','Troll','Blood Elf','Draenei']),recipeId:z.string(),goal:z.string().trim().min(1).max(200)}),
z.object({type:z.literal('lifecycle'),botId:z.string(),action:z.enum(['spawn','pause','resume','despawn','retire'])}),
z.object({type:z.literal('think'),botId:z.string()}),
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Install the isolated spectator service; public Funnel setup is a separate step."""
from pathlib import Path
import shutil
import subprocess
repo = Path(__file__).resolve().parents[2]
python = shutil.which('python3')
assert python
units = Path.home() / '.config/systemd/user'
units.mkdir(parents=True, exist_ok=True)
(units / 'azerothai-spectator.service').write_text(f'''[Unit]
Description=AzerothAI read-only spectator monitor
After=network-online.target azerothai-runtime.service
[Service]
WorkingDirectory={repo}
ExecStart={python} {repo}/scripts/spectator-server.py
Restart=always
RestartSec=5
NoNewPrivileges=true
UMask=0077
[Install]
WantedBy=default.target
''')
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
subprocess.run(['systemctl', '--user', 'enable', '--now', 'azerothai-spectator.service'], check=True)
print('Spectator ready on loopback port 8790. Public sharing:')
print('sudo tailscale funnel --bg --https=8443 http://127.0.0.1:8790')
print('Keep the existing private dashboard on port 443.')
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Install current-user runtime recovery and telemetry. Enable lingering for boot startup."""
from pathlib import Path
import shutil,subprocess
repo=Path(__file__).resolve().parents[2]
units=Path.home()/'.config/systemd/user';units.mkdir(parents=True,exist_ok=True)
node=shutil.which('node');python=shutil.which('python3');assert node and python
(units/'azerothai-runtime.service').write_text(f'''[Unit]
Description=AzerothAI web runtime
After=network-online.target
[Service]
WorkingDirectory={repo}
ExecStart={node} {repo}/scripts/start-local-runtime.mjs --lan
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=default.target
''')
(units/'azerothai-operations.service').write_text(f'''[Unit]
Description=AzerothAI telemetry and transport retention
After=azerothai-runtime.service
[Service]
Type=oneshot
WorkingDirectory={repo}
ExecStart=/usr/bin/sg docker -c "{python} {repo}/scripts/operations-agent.py"
TimeoutStartSec=50
''')
(units/'azerothai-operations.timer').write_text('''[Unit]
Description=AzerothAI one-minute health sampling
[Timer]
OnBootSec=90
OnUnitActiveSec=60
[Install]
WantedBy=timers.target
''')
subprocess.run(['systemctl','--user','daemon-reload'],check=True)
# Runtime is enabled but started separately after handing over its existing process.
subprocess.run(['systemctl','--user','enable','azerothai-runtime.service','azerothai-operations.timer'],check=True)
subprocess.run(['systemctl','--user','start','azerothai-operations.timer'],check=True)
print('User services installed. Start the runtime after stopping its previous unmanaged process.')
+32
View File
@@ -0,0 +1,32 @@
# Run as Richard at logon; no administrator password or API tokens are stored here.
$ErrorActionPreference = 'Continue'
$mutex = New-Object System.Threading.Mutex($false, 'Local\AzerothAIRecovery')
if (-not $mutex.WaitOne(0)) { exit }
$ssh = 'C:\Users\richard\Tools\MinGit\usr\bin\ssh.exe'
$key = 'C:\Users\richard\.ssh\id_ed25519_azerothai'
$ollama = 'C:\Users\richard\Tools\Ollama\ollama.exe'
$logRoot = 'C:\Users\richard\Tools\AzerothAI'
New-Item -ItemType Directory -Force $logRoot | Out-Null
while ($true) {
try {
try { Invoke-RestMethod http://127.0.0.1:11434/api/tags -TimeoutSec 3 | Out-Null }
catch {
if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) {
Start-Process $ollama -ArgumentList 'serve' -WindowStyle Hidden
}
}
$tunnel = Get-CimInstance Win32_Process -Filter "Name='ssh.exe'" | Where-Object { $_.CommandLine -like '*127.0.0.1:11434:127.0.0.1:11434*' }
if (-not $tunnel) {
Start-Process $ssh -ArgumentList @('-N','-o','BatchMode=yes','-o','StrictHostKeyChecking=yes','-o','ExitOnForwardFailure=yes','-o','ServerAliveInterval=15','-o','ServerAliveCountMax=3','-i',$key,'-R','127.0.0.1:11434:127.0.0.1:11434','endami@192.168.62.129') -WindowStyle Hidden
}
$os = Get-CimInstance Win32_OperatingSystem
$cpu = (Get-CimInstance Win32_Processor | Measure-Object LoadPercentage -Average).Average
$disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'"
$sample = @{host='windows';at=[DateTimeOffset]::UtcNow.ToUnixTimeSeconds();cpuPercent=[double]$cpu;memoryTotalMB=$os.TotalVisibleMemorySize/1024;memoryAvailableMB=$os.FreePhysicalMemory/1024;diskFreeGB=$disk.FreeSpace/1GB;maintenance='Model and SSH tunnel recovery active at Windows logon'} | ConvertTo-Json -Compress
$encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($sample))
& $ssh -o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=yes -i $key endami@192.168.62.129 "python3 /home/endami/AzerothAI/scripts/operations-agent.py --windows $encoded" 2>&1 | Out-File -Append "$logRoot\recovery.log"
} catch { $_.Exception.Message | Out-File -Append "$logRoot\recovery.log" }
$log = Get-Item "$logRoot\recovery.log" -ErrorAction SilentlyContinue
if ($log -and $log.Length -gt 1MB) { Move-Item -Force $log.FullName "$logRoot\recovery.previous.log" }
Start-Sleep -Seconds 60
}
+24
View File
@@ -0,0 +1,24 @@
# Autonomous conversations
The live bridge proposes nearby, same-faction stationary bot pairs within 15 yards. Delivery allows up to 25 yards. Both
must be alive, out of combat and in the same phase. Opportunities ride the fresh
roster snapshot transport. The database admits at most one exchange per two-minute
clock bucket and enforces ten minutes between social requests involving a bot.
Human requests take queue priority. Ordinary bot chatter does not trigger replies.
An exchange consists of a model-generated opening and one model-generated reply.
Each delivery rechecks distance, faction, phase, life and combat state in the game.
Expired requests and pairs that have moved apart are cancelled. No characters are
teleported or held in place for conversations.
Only a confirmed reply creates reciprocal observation memories and neutral
acquaintance records. This does not invent friendship or change affinity based
solely on speaking. Replay IDs prevent duplicate memories. Each resident retains
at most 100 memories and 100 relationship records on this path. Completed and
failed conversation requests are pruned after 24 hours; character memorials and
other game/event history have separate retention policies.
Memories and relationships are stored in `realm_state.payload` in the application's
D1 SQLite database under `.wrangler/state/v3/d1` in the Ubuntu deployment. This
uses the VM's disk, backed by the host PC's storage. The per-resident memory limit
is not a total disk quota: additional identities and other logs can still grow.
+67
View File
@@ -0,0 +1,67 @@
# Community and operations
## Live social behavior
The Social Life page shows the last 250 confirmed conversations, relationship
changes, invitations, departures, and guild actions. Pending requests and failed
native receipts are distinct from completed activity.
A scheduler checks fresh (under 90 seconds) world observations every 30 seconds.
Party partners must be nearby, same faction, within ten levels, and interested
in questing/exploring. Both characters decide using sociability and their directed
trust scores. Negative trust rejects the invitation. Guild recruitment requires
mutual trust of at least 3, an online bot guild leader, and a guildless candidate.
Characters never abandon an existing guild merely to fill a recruitment target.
Each participant gets ten minutes between proposals. Bot-only parties remain at
five members or fewer; native commands refuse to modify human parties or raids.
A resident may depart after ten minutes apart from all party companions.
Native receipts verify membership. Accepted parties grant +1 reciprocal trust.
Confirmed conversations grant +1, shared completed quests +3, and helping attack
the same enemy +1 (combat help is throttled per pair to five minutes). Trust is
bounded to -100..100. Replayed evidence cannot grant repeated credit within the
bounded evidence window. Memories and relationship lists retain at most 100 rows
per resident; dead/retired identities are not candidates for new activities.
Observed guilds receive an editable shared goal: quest together, support allies,
or explore other maps. The guild goal enters high-level decision context.
Progress uses observed cooperative quests, shared combat, or map changes (once
per member/map/day). Guild creation and forced cross-guild transfers are excluded.
## Health, retention and recovery
System Health displays Ubuntu and Windows samples with explicit stale markers,
CPU, available memory, free disk, application database/log sizes, and model health.
Possible stalled bots are those stationary for ten minutes while intending to
quest/explore, with fresh noncombat observations. No automatic teleport is used.
The operations agent prunes acknowledged game transport at one day, as the relay
already does. Archived snapshots and terminal application commands use the chosen
3–30 day retention (default 7). Pending work, memories, guild goals, relationship
state and memorials are preserved. Current application log files rotate at 10 MB
with three copies. Database files may retain freed SQLite pages for reuse.
The minute operations check also reconciles verified AzerothCore death records
with the native hardcore registry using character GUID, account and exact name.
Protected residents are excluded. Each correction keeps the old registry row
and supporting death in `lifecycle-reconciliation.jsonl`. The Lua guard imports
terminal registry states every ten seconds, removes the retired identity, and
allows its faction's replacement slot to fill under the existing 25/25 cap.
This handles delayed death events received after the registry was first seeded.
Install `deploy/playable/install-user-recovery.py` as the realm owner. It registers
`azerothai-runtime.service` with Restart=always and a minute operations timer in
the user's systemd manager. Enable user lingering for reboot startup. Telemetry
uses the realm owner's existing docker group, including in older user sessions.
Existing system relay/autonomy services remain owned by the system manager.
`deploy/playable/windows-recovery.ps1` checks the local model and SSH tunnel every
minute and sends Windows readings through SSH (no API token stored on Windows).
The scheduled task runs at Windows logon. If local script execution is disabled,
leave the task disabled until the user permits locally authored scripts; do not
bypass a managed execution policy. This does not start VMware or log into Windows.
The VM and PC still need to be running.
Tests: `node scripts/test-core.mjs`, TypeScript checking, and the production build.
Native commands verify identity, availability and permissions again at delivery.
A database snapshot should be taken before the first deployment.
+81
View File
@@ -0,0 +1,81 @@
# Spectator monitor
A separate loopback service serves a public, read-only projection of the realm.
It never forwards viewer paths, methods, cookies, authorization headers or query
parameters to the administrative application. It uses a fixed local GET to
`http://127.0.0.1:8787/api/state`, caches the sanitized result for ten seconds, and
marks delayed observations. Visitors refresh every fifteen seconds.
Published fields: online bot names, class, race, level, faction, zone, guild;
observed bot parties; guild goals and progress; confirmed social activity.
Only autonomous bot-to-bot reply transcripts are public. Human conversations,
whispers, memories, account/character IDs, tokens, configuration and PC telemetry
are excluded. All text is rendered with textContent, never HTML interpolation.
The HTTP service allows explicit public assets and read-only projections described
below. Mutation methods return 405; administrative command paths return 404.
## Install and publish
Run as the realm owner from the repository:
```sh
python3 scripts/test-spectator.py
python3 deploy/playable/install-spectator.py
```
The user service restarts automatically and listens only on `127.0.0.1:8790`.
User lingering, already used by runtime recovery, is required for boot startup.
Keep the existing private Tailscale Serve dashboard on HTTPS 443. Publish only
the spectator service on a separate HTTPS port:
```sh
sudo tailscale funnel --bg --https=8443 http://127.0.0.1:8790
```
If Tailscale presents a Funnel enablement link, the tailnet owner must complete
that authorization. Verify `tailscale funnel status` lists **only port 8443** as
public and port 443 remains tailnet-only. Share the printed HTTPS URL including
`:8443`. Visitors do not need Tailscale. This is a public link, without a password.
The host PC, Ubuntu VM and Tailscale must remain running.
Do not publish port 8787 or 8788, or change the administrative listener into a
Funnel. Hiding controls in the browser would not protect their APIs.
To turn public access off without disturbing the private dashboard:
```sh
sudo tailscale funnel --https=8443 off
```
To stop the local spectator service:
```sh
systemctl --user disable --now azerothai-spectator
```
After changing spectator source or assets, restart its service:
```sh
systemctl --user restart azerothai-spectator
```
## Shared dashboard (visitor edition)
The public root now serves the application's `/visitor` route, which uses the
same React dashboard and CSS as the administrator. Navigation, filters, profiles,
recipe details and the world monitor remain usable. Editing forms and GM inputs
are disabled; the action dispatcher also refuses writes in visitor mode.
The public server independently rejects every mutation method, including server
actions, and never proxies a viewer-selected backend route or request headers.
`/api/state` is an explicit public projection shaped for the shared UI. Bot memory
views contain confirmed shared social history, not private player conversations
or operator-injected memories. The event stream is a polling view of that public
history. `/api/health`, `/api/world-monitor` and `/api/operations` return selected
read-only indicators. Native account identifiers, raw command queues, credentials,
endpoint URLs and private maintenance logs are omitted. Built JavaScript/CSS/font
assets are served from `dist/client` with traversal and source-map requests denied.
Build with `pnpm build` before restarting `azerothai-runtime` and
`azerothai-spectator`. The existing Funnel address and private admin address stay
separate. No cache actions are added.
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Local telemetry and bounded terminal-log retention. No character data is deleted."""
import base64,json,os,shutil,subprocess,sys,time,urllib.request
from pathlib import Path
repo=Path(__file__).resolve().parents[1]
lab=Path(os.environ.get('AZEROTHAI_PLAYABLE_ROOT',str(Path.home()/'azerothai-pc-lab')))
def config(path):
out={}
for line in path.read_text().splitlines():
if '=' in line and not line.startswith('#'):
k,v=line.split('=',1);out[k]=v.strip().strip('"').strip("'")
return out
cfg={**config(lab/'runtime.env'),**config(repo/'.dev.vars')}
base='http://127.0.0.1:8787'
def send(data):
request=urllib.request.Request(base+'/api/operations',data=json.dumps(data).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+cfg['AUTONOMY_TOKEN']})
with urllib.request.urlopen(request,timeout=20) as r:assert r.status==200
if len(sys.argv)==3 and sys.argv[1]=='--windows':
data=json.loads(base64.b64decode(sys.argv[2]));data['host']='windows';send(data);sys.exit(0)
def bytes_in(path):
return sum(p.stat().st_size for p in path.rglob('*') if p.is_file()) if path.exists() else 0
def cpu():
v=list(map(int,Path('/proc/stat').read_text().splitlines()[0].split()[1:]));return sum(v[:8]),v[3]+v[4]
a=cpu();time.sleep(1);b=cpu();used=100*(1-(b[1]-a[1])/max(1,b[0]-a[0]))
mem={line.split(':')[0]:int(line.split()[1])/1024 for line in Path('/proc/meminfo').read_text().splitlines()}
model=False
try:
u=cfg.get('LLM_URL','').rstrip('/')+'/api/tags'
with urllib.request.urlopen(u,timeout=5) as r:model=r.status==200
except Exception:pass
maintenance=[]
# Copy/truncate preserves existing service file descriptors. Three bounded archives.
paths=[lab/'autonomy.log',lab/'ale-relay.log',Path.home()/'azerothai-direct-runtime-live.log',lab/'operations.log']
for log in paths:
if log.exists() and log.stat().st_size>10*1024*1024:
for n in [3,2]:
older=Path(str(log)+'.'+str(n-1))
if older.exists():older.replace(str(log)+'.'+str(n))
shutil.copy2(log,str(log)+'.1')
with log.open('r+') as f:f.truncate(0)
maintenance.append('Rotated '+log.name)
# Only acknowledged transport data; never purge pending messages or lifecycle history.
try:
state=json.load(urllib.request.urlopen(base+'/api/state',timeout=10));days=max(3,min(30,int(state.get('opsRetentionDays',7))))
env=os.environ.copy();env['MYSQL_PWD']=cfg['DOCKER_DB_ROOT_PASSWORD']
def sql(q):return subprocess.check_output(['docker','exec','-e','MYSQL_PWD','ac-database','mysql','-uroot','-N','-B','-e',q],env=env,text=True)
# Reconcile delayed verified deaths by the full native identity. This never
# revives an identity, changes a protected resident, or counts a human as a bot.
if state.get('settings',{}).get('hardcore',{}).get('enabled'):
retired=0
registry={int(line.split('\t')[0]):line for line in sql("SELECT guid,name,account_id,state,died_at FROM acore_auth.azerothai_lifecycle WHERE state<>'dead' AND protected=0 AND LOWER(name)<>'zaku'").splitlines() if line}
for bot in state.get('bots',[]):
identity=bot.get('autonomy') or {};death=bot.get('death') or {}
if bot.get('status')!='dead' or not death.get('verified') or death.get('source')!='azerothcore' or not identity.get('managed'):continue
if not bot.get('mortal') or bot.get('plotArmorHpFloor',0)>0 or bot.get('name','').lower()=='zaku':continue
guid=identity.get('characterGuid');account=identity.get('accountId')
if type(guid) is not int or type(account) is not int or guid<=0 or account<=0:continue
before=registry.get(guid,'');fields=before.split('\t')
if len(fields)!=5 or fields[1]!=bot['name'] or int(fields[2])!=account:continue
name=bot['name'].encode().hex()
where=f"guid={guid} AND account_id={account} AND BINARY name=UNHEX('{name}') AND protected=0 AND LOWER(name)<>'zaku' AND state<>'dead'"
# Keep an audit of the old registry row and supporting death before changing it.
audit=lab/'lifecycle-reconciliation.jsonl'
with audit.open('a') as f:f.write(json.dumps({'at':time.time(),'before':before.strip(),'death':death})+'\n')
changed=sql("UPDATE acore_auth.azerothai_lifecycle SET state='dead',died_at=COALESCE(died_at,NOW()) WHERE "+where+'; SELECT ROW_COUNT()')
retired+=int(changed.strip())
maintenance.append(f'Hardcore registry checked; {retired} delayed deaths reconciled')
for table,column in [('azerothai_event_outbox','delivered_at'),('azerothai_result_outbox','delivered_at')]:
sql(f'DELETE FROM acore_auth.{table} WHERE {column} IS NOT NULL AND {column}<NOW()-INTERVAL 1 DAY LIMIT 2000')
sql(f'DELETE FROM acore_auth.azerothai_snapshot_archive WHERE created_at<NOW()-INTERVAL {days} DAY LIMIT 2000')
maintenance.append('Acknowledged transport and snapshot retention checked')
except Exception as e:maintenance.append('Retention check needs attention: '+type(e).__name__)
service=subprocess.run(['systemctl','--user','is-active','azerothai-runtime'],capture_output=True,text=True).stdout.strip()
send({'host':'ubuntu','at':time.time(),'cpuPercent':max(0,min(100,used)),'memoryTotalMB':mem['MemTotal'],'memoryAvailableMB':mem['MemAvailable'],'diskFreeGB':shutil.disk_usage(repo).free/1024**3,'databaseMB':bytes_in(repo/'.wrangler/state/v3/d1')/1024**2,'logsMB':sum(p.stat().st_size for p in paths if p.exists())/1024**2,'modelAvailable':model,'runtimeService':service,'maintenance':'; '.join(maintenance)[:500]})
print(time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()),'telemetry and retention complete')
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""Read-only public realm projection. Never proxy arbitrary routes or headers."""
import json
import math
import os
import threading
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] / 'spectator'
ALLIANCE = {'Human', 'Dwarf', 'Night Elf', 'Gnome', 'Draenei'}
HORDE = {'Orc', 'Undead', 'Tauren', 'Troll', 'Blood Elf'}
def text(value, limit=200):
return str(value or '')[:limit]
def number(value, default=0):
return value if type(value) in (int, float) and math.isfinite(value) else default
def project(state):
"""Explicit field allowlist: no settings, IDs, memories, whispers or host data."""
managed = [b for b in state.get('bots', []) if b.get('autonomy', {}).get('managed')]
names = {b['name'] for b in managed}
bots = []
parties = {}
for b in managed:
if b.get('status') != 'online':
continue
world = b.get('world') or {}
race = text(b.get('race'), 40)
team = world.get('team')
faction = ('Alliance' if team == 0 else 'Horde' if team == 1 else
'Alliance' if race in ALLIANCE else 'Horde' if race in HORDE else 'Unknown')
bots.append({'name': text(b.get('name'), 40), 'class': text(b.get('role'), 40),
'race': race, 'level': number(b.get('level')), 'zone': text(b.get('zone'), 100),
'faction': faction, 'guild': text(world.get('guildName'), 80)})
members = sorted({text(n, 40) for n in world.get('groupNames', []) if n in names})
if world.get('groupId') and len(members) > 1:
parties[tuple(members)] = {'members': members, 'faction': faction}
community = state.get('community') or {}
feed = []
for e in community.get('feed', []):
if e.get('status') != 'confirmed' or e.get('kind') not in {'conversation', 'relationship', 'party', 'leave', 'recruit', 'guild'}:
continue
# Only completed autonomous bot-to-bot exchanges. Human conversations,
# whispers and synthetic opener prompts never enter this public feed.
if e['kind'] == 'conversation' and not (
str(e.get('id', '')).startswith('social-') and str(e.get('id', '')).endswith(':reply')
and len(e.get('names', [])) == 2 and all(n in names for n in e['names'])):
continue
feed.append({'at': text(e.get('at'), 40), 'kind': e['kind'],
'names': [text(n, 80) for n in e.get('names', [])[:5]],
'text': text(e.get('text'), 1200)})
guilds = [{'name': text(g.get('name'), 80), 'goal': text(g.get('goal'), 30),
'progress': number(g.get('progress')), 'target': number(g.get('target'), 20)}
for g in community.get('guilds', [])[:100]]
return {'rosterAt': number(state.get('aleRosterAt')), 'fetchedAt': int(time.time()),
'bots': sorted(bots, key=lambda b: (b['faction'], b['name'])),
'parties': list(parties.values()), 'guilds': guilds, 'feed': feed[-60:][::-1]}
def public_state(state):
"""Admin-shaped data for the shared read-only UI, with an explicit allowlist."""
def pick(obj, keys):
return {k: obj[k] for k in keys.split() if k in obj and isinstance(obj[k], (str, int, float, bool))}
def recipe(r):
return {**pick(r, 'id name description sociability aggression curiosity economy version'),
'tags': [text(t, 80) for t in r.get('tags', [])],
'activities': [a for a in r.get('activities', []) if a in ['quest','explore','trade','rest','socialize']]}
projected = project(state)
feed = [{**e, 'id': 'public-'+str(i), 'status': 'confirmed'} for i, e in enumerate(reversed(projected['feed']))]
bots = []
for b in state.get('bots', []):
if not b.get('autonomy', {}).get('managed'):
continue
bot = pick(b, 'id name role race level zone status tier mortal plotArmorHpFloor simulationBand homeId recipeId recipeVersion goal decisions')
bot['personality'] = recipe(b.get('personality', {}))
bot['routine'] = {**pick(b.get('routine', {}), 'wakeHour sleepHour'), 'preferredZones': b.get('routine', {}).get('preferredZones', [])}
bot['autonomy'] = {'managed': True, **pick(b.get('autonomy', {}), 'observedAt lastIntentAt nextIntentAt')}
world = b.get('world') or {}
bot['world'] = {**pick(world, 'at team guildId guildName guildRank groupId inCombat x y mapId stationarySince'),
'groupNames': [n for n in world.get('groupNames', []) if any(x['name']==n for x in state['bots'] if x.get('autonomy',{}).get('managed'))], 'nearby': []}
bot['relationships'] = [pick(r, 'targetId affinity note') for r in b.get('relationships', [])]
# Show shared history, never personal whispers or operator-injected memories.
bot['memories'] = [{'id': e['id'], 'text': e['text'], 'at': e['at'], 'source': 'observation', 'importance': 5}
for e in feed if b['name'] in e['names']]
if b.get('death'):
bot['death'] = pick(b['death'], 'id at zone level cause killer source verified')
if b.get('lastDecision'):
bot['lastDecision'] = {'action': b['lastDecision']['action'], 'reason': 'Following the resident’s current activity.', 'goal': bot.get('goal','')}
bots.append(bot)
settings = state.get('settings') or {}
return {'revision': state.get('revision',0), 'sequence': state.get('sequence',0), 'aleRosterAt': state.get('aleRosterAt',0),
'bots': bots, 'recipes': [recipe(r) for r in state.get('recipes',[])],
'homes': [{**pick(h,'id name zone type capacity storageSlots safety description'), 'ownerIds':h.get('ownerIds',[]), 'residentIds':h.get('residentIds',[])} for h in state.get('homes',[])],
'events': [{'id':e['id'],'seq':i,'at':e['at'],'kind':'relationship' if e['kind']=='relationship' else 'lifecycle','message':e['text'],'mode':'azerothcore'} for i,e in enumerate(feed)],
'settings': {**pick(settings,'connector provider model'),
'brains':pick(settings.get('brains',{}),'enabled residentModel heroModel backgroundUseLLM residentMemoryLimit heroMemoryLimit residentMaxTokens heroMaxTokens'),
'hardcore':pick(settings.get('hardcore',{}),'enabled requireVerifiedDeath memorialize'),
'population':pick(settings.get('population',{}),'defaultZoneTarget zoneMax serverMax foregroundMax backgroundMax')},
'director':{**pick(state.get('director',{}),'experience zone brief'),'playerName':''}, 'population':{}, 'operations':{},
'opsRetentionDays':state.get('opsRetentionDays',7),
'community':{'feed':feed,'evidence':[],'guilds':[{**g,'id':i} for i,g in enumerate(projected['guilds'])],
'partiesEnabled':state.get('community',{}).get('partiesEnabled',True),
'guildsEnabled':state.get('community',{}).get('guildsEnabled',True)}}
def local_get(path):
opener = urllib.request.build_opener(NoRedirect(), urllib.request.ProxyHandler({}))
with opener.open('http://127.0.0.1:8787'+path, timeout=10) as response:
return response.read(8 * 1024 * 1024)
class Snapshot:
def __init__(self):
self.lock = threading.Lock()
self.data = None
self.checked = 0
self.failed = False
def get(self):
with self.lock:
if time.monotonic() - self.checked > 10:
try:
# Fixed local endpoint. Viewer input cannot affect this request.
opener = urllib.request.build_opener(NoRedirect(), urllib.request.ProxyHandler({}))
with opener.open('http://127.0.0.1:8787/api/state', timeout=5) as response:
raw = response.read(8 * 1024 * 1024 + 1)
if len(raw) > 8 * 1024 * 1024:
raise ValueError('State too large')
state = json.loads(raw)
self.data = {**project(state), "state": public_state(state)}
self.failed = False
except Exception:
self.failed = True
self.checked = time.monotonic()
if self.data is None:
return None
return {**self.data, 'stale': self.failed or time.time() - self.data['rosterAt'] > 120}
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
SNAPSHOT = Snapshot()
ASSETS = {'/': ('index.html', 'text/html; charset=utf-8'),
'/app.js': ('app.js', 'text/javascript; charset=utf-8'),
'/style.css': ('style.css', 'text/css; charset=utf-8')}
class Handler(BaseHTTPRequestHandler):
server_version = 'AzerothAI-Spectator'
sys_version = ''
def setup(self):
super().setup()
self.connection.settimeout(10)
def reply(self, status, body, content_type='application/json; charset=utf-8'):
self.send_response(status)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('X-Frame-Options', 'DENY')
self.send_header('Referrer-Policy', 'no-referrer')
self.send_header('X-Robots-Tag', 'noindex, nofollow')
self.send_header('Content-Security-Policy', "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
self.end_headers()
if self.command != 'HEAD':
self.wfile.write(body)
def do_GET(self):
path = self.path.split('?', 1)[0]
if path in ('/', '/visitor', '/visitor/'):
try:
self.reply(200, local_get('/visitor'), 'text/html; charset=utf-8')
except Exception:
self.reply(503, b'{"error":"Realm temporarily unavailable"}')
elif path == '/api/state':
data = SNAPSHOT.get()
self.reply(200 if data else 503, json.dumps(data['state'] if data else {'error':'Realm unavailable'}).encode())
elif path == '/api/health':
data = SNAPSHOT.get()
self.reply(200, json.dumps({'ok':bool(data),'aleConfigured':True,'aleActiveRecently':bool(data and not data['stale']), 'llmConfigured':bool(data and data['state']['settings'].get('provider')!='deterministic')}).encode())
elif path == '/api/world-monitor':
data = SNAPSHOT.get()
at = data['rosterAt'] if data else 0
from datetime import datetime, timezone
self.reply(200, json.dumps({'ok':bool(data),'ale':{'lastEventAt':datetime.fromtimestamp(at,timezone.utc).isoformat() if at else None,'activeRecently':bool(data and not data['stale'])},'events':[],'commands':{}}).encode())
elif path == '/api/operations':
try:
health = json.loads(local_get('/api/operations'))
samples = [{k:v for k,v in row.items() if k in ('host','at','cpuPercent','memoryTotalMB','memoryAvailableMB','diskFreeGB','databaseMB','logsMB','modelAvailable','runtimeService')} for row in health.get('samples',[])]
self.reply(200, json.dumps({'samples':samples,'stuck':[{k:v for k,v in row.items() if k in ('name','zone','minutes')} for row in health.get('stuck',[])]}).encode())
except Exception:
self.reply(503,b'{"error":"Health data unavailable"}')
elif path.startswith(('/assets/', '/_vinext/', '/_next/')):
# Built public assets only. No source maps, dotfiles or traversal.
import mimetypes
root = ROOT.parent / 'dist/client'
asset = (root / path.lstrip('/')).resolve()
if '%' in path or '..' in path or not asset.is_relative_to(root.resolve()) or asset.suffix not in ('.js','.css','.woff','.woff2','.png','.svg','.ico') or not asset.is_file():
self.reply(404,b'{"error":"Not found"}')
else:
self.reply(200,asset.read_bytes(),mimetypes.guess_type(str(asset))[0] or 'application/octet-stream')
elif path in ASSETS:
filename, content_type = ASSETS[path]
self.reply(200, (ROOT / filename).read_bytes(), content_type)
elif path == '/api/realm':
data = SNAPSHOT.get()
self.reply(200 if data else 503, json.dumps(data or {'error': 'Realm temporarily unavailable'}).encode())
elif path == '/health':
self.reply(200, b'{"service":"spectator","readOnly":true}')
elif path == '/robots.txt':
self.reply(200, b'User-agent: *\nDisallow: /\n', 'text/plain; charset=utf-8')
else:
self.reply(404, b'{"error":"Not found"}')
do_HEAD = do_GET
def reject(self):
self.close_connection = True
self.reply(405, b'{"error":"This monitor is read-only"}')
do_POST = do_PUT = do_PATCH = do_DELETE = do_OPTIONS = do_TRACE = do_CONNECT = reject
def log_message(self, format, *args):
pass
class Server(ThreadingHTTPServer):
daemon_threads = True
slots = threading.BoundedSemaphore(24)
def process_request(self, request, client_address):
if not self.slots.acquire(blocking=False):
self.shutdown_request(request)
return
try:
super().process_request(request, client_address)
except Exception:
self.slots.release()
raise
def process_request_thread(self, request, client_address):
try:
super().process_request_thread(request, client_address)
finally:
self.slots.release()
if __name__ == '__main__':
Server(('127.0.0.1', int(os.environ.get('AZEROTHAI_SPECTATOR_PORT', '8790'))), Handler).serve_forever()
+1 -1
View File
@@ -3,4 +3,4 @@ import {writeFileSync,mkdirSync} from 'node:fs';
const result=spawnSync(process.execPath,['node_modules/typescript/bin/tsc','core/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/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'],{stdio:'inherit'}).status??1);
process.exit(spawnSync(process.execPath,['--test','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);
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
import importlib.util
import json
import threading
import unittest
from unittest.mock import patch
import urllib.error
import urllib.request
from pathlib import Path
spec = importlib.util.spec_from_file_location('spectator', Path(__file__).with_name('spectator-server.py'))
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
class SpectatorTests(unittest.TestCase):
def test_public_projection_excludes_secrets_and_whispers(self):
bots = [{'name': n, 'status': 'online', 'race': r, 'role': 'Death Knight', 'level': 55,
'autonomy': {'managed': True, 'accountId': 123}, 'memories': ['PRIVATE_MEMORY'],
'world': {'team': team, 'groupId': 'internal-guid', 'groupNames': ['A', 'B', 'HumanPlayer']}}
for n, r, team in [('A', 'Human', 0), ('B', 'Orc', 1)]]
feed = [{'id': i, 'kind': 'conversation', 'status': 'confirmed', 'names': names, 'text': t}
for i, names, t in [('whisper-1', ['HumanPlayer', 'A'], 'PRIVATE_WHISPER'),
('social-1', ['A', 'B'], 'SYNTHETIC_PROMPT'),
('social-1:reply', ['A', 'B'], 'Hello friend'),
('social-2:reply', ['HumanPlayer', 'A'], 'NOT_BOT_CHAT')]]
source={'bots': bots, 'settings': {'token': 'SECRET'}, 'community': {'feed': feed}}
projected = m.project(source)
public = m.public_state(source)
for forbidden in ['PRIVATE_MEMORY','PRIVATE_WHISPER','SYNTHETIC_PROMPT','NOT_BOT_CHAT','SECRET','accountId']:
self.assertNotIn(forbidden,json.dumps(public))
serialized = json.dumps(projected)
for private in ['PRIVATE_MEMORY', 'PRIVATE_WHISPER', 'SYNTHETIC_PROMPT', 'NOT_BOT_CHAT', 'HumanPlayer', 'SECRET', 'accountId', 'internal-guid']:
self.assertNotIn(private, serialized)
self.assertEqual([b['faction'] for b in projected['bots']], ['Alliance', 'Horde'])
self.assertEqual([e['text'] for e in projected['feed']], ['Hello friend'])
def test_http_allowlist_and_mutations(self):
server = m.Server(('127.0.0.1', 0), m.Handler)
worker = threading.Thread(target=server.serve_forever, daemon=True)
worker.start()
base = 'http://127.0.0.1:' + str(server.server_port)
try:
for route in ['/app.js', '/style.css', '/health']:
with urllib.request.urlopen(base + route) as response:
self.assertEqual(response.status, 200)
self.assertIn("default-src 'none'", response.headers['Content-Security-Policy'])
for path in ['/api/actions', '/api/ale/operator', '/.dev.vars', '/../.dev.vars', '/%2e%2e/.dev.vars', '/api/bridge/ale/poll', '/assets/../../.dev.vars', '/assets/private.map']:
with self.assertRaises(urllib.error.HTTPError) as error:
urllib.request.urlopen(base + path)
self.assertEqual(error.exception.code, 404)
error.exception.close()
with patch.object(m,'local_get',return_value=b'<html>Visitor</html>') as upstream:
with urllib.request.urlopen(base+'/') as response:
self.assertEqual(response.read(),b'<html>Visitor</html>')
upstream.assert_called_once_with('/visitor')
with patch.object(m.SNAPSHOT,'get',return_value={'state':{'bots':[]}}):
with urllib.request.urlopen(base+'/api/state') as response:
self.assertEqual(json.load(response),{'bots':[]})
for method in ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'TRACE']:
with self.assertRaises(urllib.error.HTTPError) as error:
urllib.request.urlopen(urllib.request.Request(base + '/api/actions', method=method, data=b'{}'))
self.assertEqual(error.exception.code, 405)
error.exception.close()
finally:
server.shutdown()
server.server_close()
worker.join()
if __name__ == '__main__':
unittest.main()
+46
View File
@@ -0,0 +1,46 @@
'use strict';
const $ = id => document.getElementById(id);
let realm;
function el(tag, text, cls) { const node = document.createElement(tag); if (text !== undefined) node.textContent = String(text); if (cls) node.className = cls; return node; }
function empty(node, text) { node.replaceChildren(el('p', text, 'empty')); }
function renderRoster() {
if (!realm) return;
const term = $('search').value.trim().toLowerCase(), faction = $('faction').value;
const bots = realm.bots.filter(b => (faction === 'all' || b.faction === faction) && [b.name,b.class,b.race,b.zone,b.guild].join(' ').toLowerCase().includes(term));
$('roster').replaceChildren(...bots.map(b => {
const row = el('tr'); row.append(el('td', b.name));
const team = el('td'); team.append(el('span', b.faction, 'tag ' + b.faction.toLowerCase())); row.append(team);
const role = el('td', b.class); role.append(el('small', b.race)); row.append(role, el('td', b.level), el('td', b.zone || 'Unknown'), el('td', b.guild || '—')); return row;
}));
$('roster-empty').hidden = bots.length > 0;
$('roster-empty').textContent = realm.bots.length ? 'No residents match this search.' : 'No residents are currently reported online.';
}
function renderFeed() {
if (!realm) return;
const entries = realm.feed.filter(e => $('activity-filter').value === 'all' || e.kind === $('activity-filter').value);
if (!entries.length) return empty($('feed'), 'No confirmed activity of this type yet. New encounters will appear here.');
$('feed').replaceChildren(...entries.map(e => {
const node = el('article', undefined, 'entry');
node.append(el('span', e.kind + ' · ' + new Date(e.at).toLocaleString(), 'meta'), el('h3', e.names.join(' ↔ ')), el('p', e.text)); return node;
}));
}
function render() {
$('total').textContent = realm.bots.length;
$('alliance').textContent = realm.bots.filter(b => b.faction === 'Alliance').length;
$('horde').textContent = realm.bots.filter(b => b.faction === 'Horde').length;
$('parties-count').textContent = realm.parties.length;
$('status').className = 'status' + (realm.stale ? ' stale' : '');
$('status').textContent = (realm.stale ? 'Updates delayed · Showing last known roster' : 'Live realm observations') + (realm.rosterAt ? ' · Observed ' + new Date(realm.rosterAt * 1000).toLocaleTimeString() : '');
renderRoster(); renderFeed();
$('parties').replaceChildren(...realm.parties.map(p => { const n=el('article',undefined,'group');n.append(el('strong',p.members.join(' · ')),el('p',p.faction));return n; }));
if (!realm.parties.length) empty($('parties'), 'No bot parties observed yet.');
const goals={quest:'Complete quests together',support:'Help guildmates in combat',explore:'Explore other maps'};
$('guilds').replaceChildren(...realm.guilds.map(g => { const n=el('article',undefined,'group');n.append(el('strong',g.name),el('p',goals[g.goal]||'Shared adventures'),el('p',g.progress+' contributions · milestone '+g.target));const p=el('progress');p.max=Math.max(1,g.target);p.value=g.progress;p.setAttribute('aria-label',g.name+' goal progress');n.append(p);return n; }));
if (!realm.guilds.length) empty($('guilds'), 'Waiting for guild observations.');
}
async function refresh() {
try { const response=await fetch('/api/realm',{cache:'no-store',signal:AbortSignal.timeout(10000)});if(!response.ok)throw new Error();realm=await response.json();render(); }
catch { $('status').className='status stale';$('status').textContent=realm?'Connection interrupted · Showing the last observation. Retrying automatically.':'Realm temporarily unavailable. Retrying automatically.'; }
finally { setTimeout(refresh,15000); }
}
$('search').addEventListener('input',renderRoster);$('faction').addEventListener('change',renderRoster);$('activity-filter').addEventListener('change',renderFeed);refresh();
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>AzerothAI · Living Realm</title><link rel="stylesheet" href="/style.css"><script src="/app.js" defer></script></head>
<body><main>
<header><div><p class="eyebrow">AZEROTHAI / SPECTATOR</p><h1>A world with a life of its own.</h1><p class="intro">Follow the residents of Azeroth as they adventure, meet, and build a shared history.</p></div><span class="badge">View only</span></header>
<div class="status" role="status" id="status">Connecting to the realm…</div>
<section class="stats" aria-label="Realm population"><article><span>Residents online</span><strong id="total">—</strong><small>50 resident limit</small></article><article class="alliance"><span>Alliance</span><strong id="alliance">—</strong><small>25 resident target</small></article><article class="horde"><span>Horde</span><strong id="horde">—</strong><small>25 resident target</small></article><article><span>Adventuring parties</span><strong id="parties-count">—</strong><small>Observed bot groups</small></article></section>
<nav aria-label="Realm sections"><a href="#residents">Residents</a><a href="#activity">Social life</a><a href="#groups">Parties & guilds</a></nav>
<section id="residents" class="panel"><div class="heading"><div><p class="eyebrow">IN THE WORLD</p><h2>Meet the residents</h2></div><div class="filters"><label><span class="sr-only">Search residents</span><input id="search" type="search" placeholder="Find a name, class, or zone…"></label><label><span class="sr-only">Faction</span><select id="faction"><option value="all">Both factions</option><option>Alliance</option><option>Horde</option></select></label></div></div><div class="table-wrap"><table><thead><tr><th>Name</th><th>Faction</th><th>Class / race</th><th>Level</th><th>Location</th><th>Guild</th></tr></thead><tbody id="roster"></tbody></table></div><p id="roster-empty" class="empty">Waiting for the first observation.</p></section>
<div class="columns"><section id="activity" class="panel"><div class="heading"><div><p class="eyebrow">SHARED STORIES</p><h2>Social life</h2></div><label><span class="sr-only">Activity type</span><select id="activity-filter"><option value="all">All activity</option><option value="conversation">Conversations</option><option value="relationship">Relationships</option></select></label></div><div id="feed" class="feed"></div></section><section id="groups" class="panel"><div class="heading"><div><p class="eyebrow">BETTER TOGETHER</p><h2>Parties & guilds</h2></div></div><div class="group-body"><h3>Current parties</h3><div id="parties"></div><h3 class="guild-title">Guild ambitions</h3><div id="guilds"></div></div></section></div>
<footer>Automatic updates every 15 seconds · Population can briefly dip while a replacement joins.<br>Bot-to-bot conversations only. Player whispers stay private.</footer>
</main></body></html>
+1
View File
@@ -0,0 +1 @@
:root{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#e6e8ed;background:#0b1017;font-synthesis:none}*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 15% 0,#23314455,transparent 55%)}main{max-width:1280px;margin:auto;padding:48px 28px}header{display:flex;justify-content:space-between;gap:24px;align-items:flex-start}h1{font-family:Georgia,serif;font-size:clamp(30px,4vw,48px);font-weight:400;letter-spacing:-1px;margin:12px 0}h2{font-size:22px;margin:5px 0}h3{font-size:15px;margin:0 0 14px}.eyebrow{font-size:11px;letter-spacing:2px;color:#c3a56f;margin:0}.intro{max-width:640px;color:#a9b2c1;line-height:1.7}.badge{border:1px solid #415245;border-radius:30px;color:#a8c8a5;white-space:nowrap;padding:8px 14px;font-size:12px}.status{color:#a9b2c1;margin:20px 0;font-size:13px}.status.stale{color:#f2bd79}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}.stats article{background:#121a25;border:1px solid #293241;border-radius:14px;padding:22px;display:flex;flex-direction:column;gap:6px}.stats span{font-size:13px;color:#bec7d3}.stats strong{font-size:40px;font-weight:500;color:#dcc18e}.stats small{font-size:11px;color:#8e9aaa}.stats .alliance strong{color:#8dbbff}.stats .horde strong{color:#ef9995}nav{display:flex;gap:25px;padding:26px 0}a{color:#bcc7d5;text-decoration:none;font-size:14px}a:hover{color:#efd69f}.panel{background:#111923;border:1px solid #293241;border-radius:14px;overflow:hidden;scroll-margin-top:20px}.heading{padding:24px;display:flex;align-items:center;justify-content:space-between;gap:18px;border-bottom:1px solid #27303c}.filters{display:flex;gap:8px}input,select{font:inherit;font-size:13px;background:#0c131c;border:1px solid #354153;color:#dfe5ec;padding:11px;border-radius:7px}input{width:250px}input:focus,select:focus,a:focus-visible{outline:2px solid #c3a56f;outline-offset:3px}.table-wrap{overflow-x:auto}table{width:100%;border-collapse:collapse;text-align:left;white-space:nowrap}th{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#919eaf;padding:15px 22px}td{font-size:13px;padding:15px 22px;border-top:1px solid #202b38;color:#b5c0ce}td:first-child{color:#eee6d9;font-weight:600}td small{display:block;color:#8b98aa;font-size:11px;margin-top:4px}.tag{font-size:11px;border-radius:4px;padding:4px 7px;background:#263347;color:#a6c8fb}.tag.horde{background:#38282e;color:#eab0a7}.columns{display:grid;grid-template-columns:1.25fr 1fr;gap:22px;margin-top:22px;align-items:start}.feed{max-height:660px;overflow-y:auto;padding:0 24px}.entry{padding:22px 0;border-bottom:1px solid #293241}.entry:last-child{border:0}.entry .meta{font-size:10px;color:#a6b0bd;text-transform:uppercase;letter-spacing:.8px}.entry h3{margin:10px 0;color:#d8c19a}.entry p{font-size:14px;line-height:1.7;white-space:pre-wrap;margin:0;color:#b6c1d0;overflow-wrap:anywhere}.group-body{padding:24px}.group{background:#17212e;border-radius:9px;padding:16px;margin-bottom:12px;font-size:14px;line-height:1.6}.group strong{display:block;color:#dfcfb2}.group p{font-size:12px;color:#aab7c8;margin:5px 0}.guild-title{margin-top:30px}progress{width:100%;height:6px;accent-color:#bea573}.empty{color:#95a2b4;font-size:13px;line-height:1.7;padding:20px;margin:0}footer{margin-top:30px;color:#8593a6;font-size:12px;line-height:1.9}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:850px){.columns{grid-template-columns:1fr}.heading{align-items:flex-start;flex-direction:column}.filters{width:100%}input{width:100%}.filters label:first-child{flex:1}}@media(max-width:540px){main{padding:28px 16px}.stats{grid-template-columns:1fr 1fr;gap:10px}.stats article{padding:17px}.stats strong{font-size:34px}header{display:block}.badge{display:inline-block;margin-top:8px}.heading{padding:20px}.filters{flex-direction:column}select{max-width:100%}nav{gap:20px}nav a{font-size:13px}}
+39
View File
@@ -0,0 +1,39 @@
const {test}=require('node:test');
const assert=require('node:assert/strict');
const {partyDecision,bondEvidence,community,compileCommunityAction,observePresence,applyCooperation}=require('../.test-build/community.js');
const now=()=>Date.now()/1000;
function bot(id){return {id,name:id,status:'online',level:5,autonomy:{managed:true,characterGuid:id==='A'?1:2},personality:{sociability:60,activities:['quest']},relationships:[],memories:[],world:{at:now(),team:1,nearby:id==='A'?['B']:['A'],inCombat:false,groupNames:[],groupId:'',guildId:3,guildName:'Guild',guildRank:0,mapId:0,x:1,y:1}};}
test('party acceptance respects faction, level, hostility, combat, and observation freshness',()=>{
const a=bot('A'),b=bot('B');assert.equal(partyDecision(a,b).accepted,true);
b.world.team=0;assert.equal(partyDecision(a,b).accepted,false);b.world.team=1;
b.level=55;assert.equal(partyDecision(a,b).accepted,false);b.level=5;
b.relationships=[{targetId:'A',affinity:-10}];assert.equal(partyDecision(a,b).accepted,false);b.relationships=[];
b.world.at=now()-150;assert.equal(partyDecision(a,b).accepted,false);b.world.at=now();
b.world.inCombat=true;assert.equal(partyDecision(a,b).accepted,false);
});
test('relationship evidence is reciprocal, replay safe, bounded, and excludes deaths',()=>{
const a=bot('A'),b=bot('B'),s={bots:[a,b]};
bondEvidence(s,'x',a,b,'Helped',3);bondEvidence(s,'x',a,b,'Helped',3);
assert.equal(a.relationships[0].affinity,3);assert.equal(b.relationships[0].affinity,3);
applyCooperation(s,{observedAt:now(),evidenceId:'q',characterGuid:1,peerGuid:2,cooperation:'quest',questId:5});
assert.equal(a.relationships[0].affinity,6);assert.equal(a.memories.length,1);
applyCooperation(s,{observedAt:now(),evidenceId:'q',characterGuid:1,peerGuid:2,cooperation:'quest',questId:5});assert.equal(a.memories.length,1);
b.status='dead';applyCooperation(s,{observedAt:now(),evidenceId:'q2',characterGuid:1,peerGuid:2,cooperation:'quest'});assert.equal(a.memories.length,1);
});
test('native commands protect human parties, cap party size, verify identities and enforce guild authority',()=>{
const a=bot('A'),b=bot('B');
const p=compileCommunityAction('party',a,b);assert.match(p,/Human party is protected/);assert.match(p,/GetMembersCount\(\)>=5/);assert.match(p,/GroupCreate/);assert.match(p,/GetGUIDLow\(\)~=2/);
const g=compileCommunityAction('recruit',a,b);assert.match(g,/GetGuildRank\(\)~=0/);assert.match(g,/Already belongs to a guild/);
assert.match(compileCommunityAction('leave',a),/Party changed/);
});
test('fresh movement snapshots update guild registry and stale snapshots cannot create stuck alerts',()=>{
const a=bot('A'),s={bots:[a]};a.world.at=now()-30;
observePresence(s,a,{observedAt:now(),team:1,x:10,y:20,mapId:0,guildId:3,guildName:'Guild',groupId:'',nearbyBots:'B'});
assert.equal(community(s).guilds[0].name,'Guild');const at=a.world.at;
observePresence(s,a,{observedAt:now()-500,team:1,x:2,y:2});assert.equal(a.world.at,at);
});
test('offline party members are protected when their identity cannot be checked live',()=>{
assert.match(compileCommunityAction('party',bot('A'),bot('B')),/Party has offline members/);
assert.match(compileCommunityAction('leave',bot('A')),/Party has offline members/);
});
+2
View File
@@ -2,6 +2,7 @@ const {test}=require('node:test');
const assert=require('node:assert/strict');
const queue=require('../.test-build/conversation-store.js');
const ale=require('../.test-build/ale-store.js');
require('../.test-build/community.js').communityTick=async()=>{};
const {runConversationTick}=require('../.test-build/conversation.js');
test('conversation failure, queued delivery, confirmed memory and replay safety',async()=>{
@@ -69,6 +70,7 @@ test('social exchanges remember both residents only after confirmed reply and re
await runConversationTick(store,{},{});
assert.equal(a.memories.length,1);assert.equal(b.memories.length,1);
assert.equal(a.relationships[0].targetId,'b');assert.equal(b.relationships[0].targetId,'a');
assert.equal(a.relationships[0].affinity,1);assert.equal(b.relationships[0].affinity,1);
await runConversationTick(store,{},{});
assert.equal(a.memories.length,1);assert.equal(b.memories.length,1);assert.equal(followups,1);
}finally{global.fetch=originalFetch;}