From 46935c791e12dbdc04235c07c55aac94ffe8413b Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 22 Sep 2026 17:43:07 -0400 Subject: [PATCH] Add social feed, cooperative parties, guild goals, and operations recovery --- ale/azerothai_bridge.lua | 55 ++++++++++- app/api/operations/route.ts | 25 +++++ app/community-panel.tsx | 23 +++++ app/page.tsx | 5 +- core/autonomy.ts | 2 + core/community.ts | 118 +++++++++++++++++++++++ core/conversation.ts | 10 +- core/service.ts | 10 ++ core/types.ts | 8 +- core/validation.ts | 3 + deploy/playable/install-user-recovery.py | 42 ++++++++ deploy/playable/windows-recovery.ps1 | 32 ++++++ docs/community-and-operations.md | 59 ++++++++++++ scripts/operations-agent.py | 54 +++++++++++ scripts/test-core.mjs | 2 +- tests/community.test.cjs | 39 ++++++++ tests/conversation.test.cjs | 2 + 17 files changed, 477 insertions(+), 12 deletions(-) create mode 100644 app/api/operations/route.ts create mode 100644 app/community-panel.tsx create mode 100644 core/community.ts create mode 100644 deploy/playable/install-user-recovery.py create mode 100644 deploy/playable/windows-recovery.ps1 create mode 100644 docs/community-and-operations.md create mode 100644 scripts/operations-agent.py create mode 100644 tests/community.test.cjs diff --git a/ale/azerothai_bridge.lua b/ale/azerothai_bridge.lua index 9f6062d..98c3a33 100644 --- a/ale/azerothai_bridge.lua +++ b/ale/azerothai_bridge.lua @@ -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) diff --git a/app/api/operations/route.ts b/app/api/operations/route.ts new file mode 100644 index 0000000..cf71942 --- /dev/null +++ b/app/api/operations/route.ts @@ -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_at0)").bind(before).run(); + return Response.json({ok:true}); + }catch(e){return Response.json({error:e instanceof Error?e.message:'Invalid telemetry'},{status:400});} +} diff --git a/app/community-panel.tsx b/app/community-panel.tsx new file mode 100644 index 0000000..909f207 --- /dev/null +++ b/app/community-panel.tsx @@ -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;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>((groups,b)=>{(groups[b.world!.groupId]??=[]).push(b);return groups;},{})); + return <> +

Cooperative play

Maximum 50 residents. Group and guild changes appear as confirmed only after the game accepts them.

+ + +
+

Live parties

{parties.length?parties.map(([id,bots])=>
{bots?.map(b=>b.name).join(', ')}

{bots?.[0]?.zone} · {bots?.length} observed members

):

No parties currently observed.

}
+

Guild goals

{c?.guilds.length?c.guilds.map(g=>
{g.name}

{g.progress} confirmed contributions · milestone {g.target}

):

Waiting for guild observations.

}
+

Social activity

{[...(c?.feed??[])].reverse().filter(e=>filter==='all'||e.kind===filter).map(e=>
{new Date(e.at).toLocaleString()} · {e.kind} · {e.status??'observed'}

{e.names.join(' ↔ ')}

{e.text}

{e.affinityDelta!==undefined&&Trust change: {e.affinityDelta>0?'+':''}{e.affinityDelta}}
)}{!c?.feed.length&&

No confirmed social activity recorded yet.

}
+ ; +} +export function OperationsPanel({state,act,busy}:{state:State;act:(a:any)=>Promise;busy:boolean}){ + const [data,setData]=useState(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 <>
{['ubuntu','windows'].map(host=>{const s=data?.samples.find((s:any)=>s.host===host),stale=!s||Date.now()/1000-s.at>180;return

{host==='ubuntu'?'Ubuntu server':'Windows PC'}

{s?<>

{stale?'Telemetry stale — values may have changed':'Current telemetry'} · {new Date(s.at*1000).toLocaleTimeString()}

{[['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)=>
{label}{value}
)}{s.memoryAvailableMB<1024&&

Memory pressure: keep the population capped at 50.

}

{s.maintenance}

:

Waiting for telemetry from this machine.

}
;})}
+

Storage and stalled activity

{error&&

{error}

}

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.

Possible stalled bots

Stationary for over 10 minutes while intending to quest or explore. This is a review signal, not proof of being stuck; no automatic teleporting.

{data?.stuck?.length?data.stuck.map((b:any)=>

{b.name} · {b.zone} · {b.minutes} minutes

):

No candidates detected.

}
; +} diff --git a/app/page.tsx b/app/page.tsx index b73b0d7..036810e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,4 +1,5 @@ 'use client'; +import {CommunityPanel,OperationsPanel} from './community-panel'; import { useState,useEffect,useCallback } from 'react'; import { LayoutDashboard,Users,BookOpen,Compass,Server,Activity,Brain,Sparkles,Plus,Play,Pause,ArrowRight,Shield,Terminal,ScrollText,Radio,RefreshCw,Heart,MapPin,Network,Copy,Send,House,Globe2,Moon,Sun,Skull,Clock3 } from 'lucide-react'; import { SidebarProvider,Sidebar,SidebarHeader,SidebarContent,SidebarMenu,SidebarMenuItem,SidebarMenuButton,SidebarFooter,SidebarTrigger } from '@/components/ui/sidebar'; @@ -16,7 +17,7 @@ import { Table,TableHeader,TableRow,TableHead,TableBody,TableCell } from '@/comp import { Skeleton } from '@/components/ui/skeleton'; import { Toaster,toast } from 'sonner'; import type { State,Bot,Recipe,RealmEvent,Activity as BotActivity } from '@/core/types'; -const NAV=[['Dashboard',LayoutDashboard],['World Monitor',Globe2],['Realm Director',Compass],['World & Housing',House],['Bots',Users],['Bot Recipes',BookOpen],['Memory & Bonds',Heart],['Event Stream',Activity],['GM Console',Terminal],['Server & Models',Server]] as const; +const NAV=[['Dashboard',LayoutDashboard],['World Monitor',Globe2],['Social Life',Heart],['System Health',Activity],['Realm Director',Compass],['World & Housing',House],['Bots',Users],['Bot Recipes',BookOpen],['Memory & Bonds',Heart],['Event Stream',Activity],['GM Console',Terminal],['Server & Models',Server]] as const; const ACTIVITIES:BotActivity[]=['explore','quest','socialize','trade','rest']; function Choice({value,onChange,items,label}:{value:string;onChange:(v:string)=>void;items:{value:string;label:string}[];label:string}){return } const options=(items:string[])=>items.map(value=>({value,label:value})); @@ -47,6 +48,8 @@ export default function Home(){ {page==='Bot Recipes'&&<>
Recipes are versioned. Saving a recipe preserves existing bots’ personalities until you explicitly apply the new version.
{state.recipes.map((r,i)=>)}
} {page==='Realm Director'&&} {page==='Memory & Bonds'&&
{state.bots.map(b=>setBotId(b.id)}>Open profile}>

{b.memories.length} memories · {b.relationships.length} relationships

{b.memories.at(-1)?.text??'No memories yet.'}

{b.relationships.map(r=>

{state.bots.find(x=>x.id===r.targetId)?.name} · {r.affinity>0?'+':''}{r.affinity} affinity
{r.note}

)}
)}
} + {page==='Social Life'&&} + {page==='System Health'&&} {page==='Event Stream'&&} {page==='Server & Models'&&} {page==='GM Console'&&} diff --git a/core/autonomy.ts b/core/autonomy.ts index 96bfa38..1f5350b 100644 --- a/core/autonomy.ts +++ b/core/autonomy.ts @@ -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; diff --git a/core/community.ts b/core/community.ts new file mode 100644 index 0000000..149d592 --- /dev/null +++ b/core/community.ts @@ -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){ + 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){ + 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(); + for(const action of pending.results){ + const receipt=await db.prepare('SELECT status,error FROM ale_commands WHERE id=?').bind(action.command_id).first(); + 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 atfresh(b)); + const recent=await db.prepare('SELECT a,b FROM community_actions WHERE created_at>?').bind(new Date(now-10*60_000).toISOString()).all(); + 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'})); + } +} diff --git a/core/conversation.ts b/core/conversation.ts index 5539772..25339a1 100644 --- a/core/conversation.ts +++ b/core/conversation.ts @@ -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'}; diff --git a/core/service.ts b/core/service.ts index b3308fb..cb4a017 100644 --- a/core/service.ts +++ b/core/service.ts @@ -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){ 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); diff --git a/core/types.ts b/core/types.ts index d247abb..76a07cd 100644 --- a/core/types.ts +++ b/core/types.ts @@ -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}; +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}; 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; lifecycle(bot:Bot,action:'spawn'|'pause'|'resume'|'despawn'|'retire',requestId:string):Promise; decide(bot:Bot,decision:Decision,requestId:string):Promise<{summary:string;zone?:string}>;} diff --git a/core/validation.ts b/core/validation.ts index 1189aad..b068abf 100644 --- a/core/validation.ts +++ b/core/validation.ts @@ -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()}), diff --git a/deploy/playable/install-user-recovery.py b/deploy/playable/install-user-recovery.py new file mode 100644 index 0000000..6e4ef4d --- /dev/null +++ b/deploy/playable/install-user-recovery.py @@ -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.') diff --git a/deploy/playable/windows-recovery.ps1 b/deploy/playable/windows-recovery.ps1 new file mode 100644 index 0000000..16f4a68 --- /dev/null +++ b/deploy/playable/windows-recovery.ps1 @@ -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 +} diff --git a/docs/community-and-operations.md b/docs/community-and-operations.md new file mode 100644 index 0000000..1bc5b3a --- /dev/null +++ b/docs/community-and-operations.md @@ -0,0 +1,59 @@ +# 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. + +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. diff --git a/scripts/operations-agent.py b/scripts/operations-agent.py new file mode 100644 index 0000000..21bfb3a --- /dev/null +++ b/scripts/operations-agent.py @@ -0,0 +1,54 @@ +#!/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) + 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}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/); +}); diff --git a/tests/conversation.test.cjs b/tests/conversation.test.cjs index 389976e..3f7f562 100644 --- a/tests/conversation.test.cjs +++ b/tests/conversation.test.cjs @@ -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;}