Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
210b715a6f | ||
|
|
7e19d82a02 | ||
|
|
8e96c7ff47 | ||
|
|
46935c791e |
@@ -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
|
||||
|
||||
@@ -219,7 +233,7 @@ function AzerothAIEmitSnapshots()
|
||||
end
|
||||
emit("player.snapshot",roster)
|
||||
for _,player in pairs(players) do
|
||||
if is_playerbot(player) then emit("player.snapshot",player_payload(player)) end
|
||||
if is_playerbot(player) then emit("player.snapshot",world_payload(player)) end
|
||||
end
|
||||
return emitted
|
||||
end
|
||||
@@ -382,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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});}
|
||||
}
|
||||
@@ -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></>;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+21
-17
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
'use client';
|
||||
import Home from '../page';
|
||||
export default function Visitor(){return <Home readOnly/>;}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'}));
|
||||
}
|
||||
}
|
||||
@@ -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'};
|
||||
|
||||
|
||||
@@ -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
@@ -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}>;}
|
||||
|
||||
@@ -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()}),
|
||||
|
||||
@@ -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.')
|
||||
@@ -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.')
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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}}
|
||||
@@ -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,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;}
|
||||
|
||||
Reference in New Issue
Block a user