Add social feed, cooperative parties, guild goals, and operations recovery
This commit is contained in:
@@ -112,6 +112,9 @@ local function player_payload(player)
|
|||||||
raceName=player:GetRaceAsString() or tostring(player:GetRace()),
|
raceName=player:GetRaceAsString() or tostring(player:GetRace()),
|
||||||
className=player:GetClassAsString() or tostring(player:GetClass()),
|
className=player:GetClassAsString() or tostring(player:GetClass()),
|
||||||
healthPct=player:GetHealthPct(),
|
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
|
isBot=isBot
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -138,11 +141,22 @@ local function nearby_bot_names(player)
|
|||||||
return table.concat(names,",")
|
return table.concat(names,",")
|
||||||
end
|
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)
|
function AzerothAIEmitPlayerSnapshot(name)
|
||||||
if not name or name == "" then return 0 end
|
if not name or name == "" then return 0 end
|
||||||
local ok,player=pcall(function() return GetPlayerByName(name) 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
|
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
|
return 1
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -219,7 +233,7 @@ function AzerothAIEmitSnapshots()
|
|||||||
end
|
end
|
||||||
emit("player.snapshot",roster)
|
emit("player.snapshot",roster)
|
||||||
for _,player in pairs(players) do
|
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
|
end
|
||||||
return emitted
|
return emitted
|
||||||
end
|
end
|
||||||
@@ -382,3 +396,40 @@ end
|
|||||||
|
|
||||||
emit("bridge.ready",{pollMs=POLL_MS,protectedCharacter=PROTECTED})
|
emit("bridge.ready",{pollMs=POLL_MS,protectedCharacter=PROTECTED})
|
||||||
PrintInfo("[AzerothAI] ALE bridge loaded; durable DB command polling every " .. tostring(POLL_MS) .. " ms")
|
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)
|
||||||
|
|||||||
@@ -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></>;
|
||||||
|
}
|
||||||
+4
-1
@@ -1,4 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import {CommunityPanel,OperationsPanel} from './community-panel';
|
||||||
import { useState,useEffect,useCallback } from 'react';
|
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 { 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';
|
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 { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Toaster,toast } from 'sonner';
|
import { Toaster,toast } from 'sonner';
|
||||||
import type { State,Bot,Recipe,RealmEvent,Activity as BotActivity } from '@/core/types';
|
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'];
|
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 <Select value={value} onValueChange={onChange}><SelectTrigger aria-label={label}><SelectValue/></SelectTrigger><SelectContent>{items.map(i=><SelectItem key={i.value} value={i.value}>{i.label}</SelectItem>)}</SelectContent></Select>}
|
function Choice({value,onChange,items,label}:{value:string;onChange:(v:string)=>void;items:{value:string;label:string}[];label:string}){return <Select value={value} onValueChange={onChange}><SelectTrigger aria-label={label}><SelectValue/></SelectTrigger><SelectContent>{items.map(i=><SelectItem key={i.value} value={i.value}>{i.label}</SelectItem>)}</SelectContent></Select>}
|
||||||
const options=(items:string[])=>items.map(value=>({value,label:value}));
|
const options=(items:string[])=>items.map(value=>({value,label:value}));
|
||||||
@@ -47,6 +48,8 @@ export default function Home(){
|
|||||||
{page==='Bot Recipes'&&<><div className="notice">Recipes are versioned. Saving a recipe preserves existing bots’ personalities until you explicitly apply the new version.</div><div className="recipe-grid">{state.recipes.map((r,i)=><button key={r.id} className="panel recipe-card" onClick={()=>{setRecipe(r);setRecipeOpen(true)}}><div style={{display:'flex',justifyContent:'space-between'}}><span className="recipe-symbol">{i%2?<Compass size={23}/>:<BookOpen size={23}/>}</span><small>VERSION {r.version}</small></div><h3>{r.name}</h3><p>{r.description}</p><div className="tags">{r.tags.map(t=><span className="tag" key={t}>{t}</span>)}</div><div style={{display:'flex',justifyContent:'space-between',marginTop:21}}><small>{state.bots.filter(b=>b.recipeId===r.id).length} bot identities</small><small style={{color:'#9abef7'}}>Edit recipe →</small></div></button>)}</div></>}
|
{page==='Bot Recipes'&&<><div className="notice">Recipes are versioned. Saving a recipe preserves existing bots’ personalities until you explicitly apply the new version.</div><div className="recipe-grid">{state.recipes.map((r,i)=><button key={r.id} className="panel recipe-card" onClick={()=>{setRecipe(r);setRecipeOpen(true)}}><div style={{display:'flex',justifyContent:'space-between'}}><span className="recipe-symbol">{i%2?<Compass size={23}/>:<BookOpen size={23}/>}</span><small>VERSION {r.version}</small></div><h3>{r.name}</h3><p>{r.description}</p><div className="tags">{r.tags.map(t=><span className="tag" key={t}>{t}</span>)}</div><div style={{display:'flex',justifyContent:'space-between',marginTop:21}}><small>{state.bots.filter(b=>b.recipeId===r.id).length} bot identities</small><small style={{color:'#9abef7'}}>Edit recipe →</small></div></button>)}</div></>}
|
||||||
{page==='Realm Director'&&<Director state={state} act={act} busy={busy}/>}
|
{page==='Realm Director'&&<Director state={state} act={act} busy={busy}/>}
|
||||||
{page==='Memory & Bonds'&&<div className="recipe-grid">{state.bots.map(b=><Panel key={b.id} title={b.name} icon={Heart} action={<Button variant="ghost" size="sm" onClick={()=>setBotId(b.id)}>Open profile</Button>}><div className="panel-body"><p className="list-muted">{b.memories.length} memories · {b.relationships.length} relationships</p><div className="memory"><p>{b.memories.at(-1)?.text??'No memories yet.'}</p></div>{b.relationships.map(r=><p className="list-muted" key={r.targetId}>{state.bots.find(x=>x.id===r.targetId)?.name} · {r.affinity>0?'+':''}{r.affinity} affinity<br/>{r.note}</p>)}</div></Panel>)}</div>}
|
{page==='Memory & Bonds'&&<div className="recipe-grid">{state.bots.map(b=><Panel key={b.id} title={b.name} icon={Heart} action={<Button variant="ghost" size="sm" onClick={()=>setBotId(b.id)}>Open profile</Button>}><div className="panel-body"><p className="list-muted">{b.memories.length} memories · {b.relationships.length} relationships</p><div className="memory"><p>{b.memories.at(-1)?.text??'No memories yet.'}</p></div>{b.relationships.map(r=><p className="list-muted" key={r.targetId}>{state.bots.find(x=>x.id===r.targetId)?.name} · {r.affinity>0?'+':''}{r.affinity} affinity<br/>{r.note}</p>)}</div></Panel>)}</div>}
|
||||||
|
{page==='Social Life'&&<CommunityPanel state={state} act={act} busy={busy}/>}
|
||||||
|
{page==='System Health'&&<OperationsPanel state={state} act={act} busy={busy}/>}
|
||||||
{page==='Event Stream'&&<EventStream state={state}/>}
|
{page==='Event Stream'&&<EventStream state={state}/>}
|
||||||
{page==='Server & Models'&&<Settings state={state} act={act} busy={busy} capabilities={capabilities}/>}
|
{page==='Server & Models'&&<Settings state={state} act={act} busy={busy} capabilities={capabilities}/>}
|
||||||
{page==='GM Console'&&<GM state={state} act={act} busy={busy} capabilities={capabilities} setConfirm={setConfirm}/>}
|
{page==='GM Console'&&<GM state={state} act={act} busy={busy} capabilities={capabilities} setConfirm={setConfirm}/>}
|
||||||
|
|||||||
@@ -141,6 +141,8 @@ export async function runLiveAutonomyTick(
|
|||||||
|
|
||||||
let bot=pick.bot;
|
let bot=pick.bot;
|
||||||
const observation=observationFrom(bot,pick.obs,events);
|
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);
|
const brain=providerForBot(bot,s.settings,config);
|
||||||
let decision:Decision;
|
let decision:Decision;
|
||||||
let usedFallback=false;
|
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 { z } from 'zod';
|
||||||
import { queueAleCommand } from './ale-store';
|
import { queueAleCommand } from './ale-store';
|
||||||
import { luaString } from './ale';
|
import { luaString } from './ale';
|
||||||
@@ -145,14 +146,10 @@ async function remember(store:StateStore,botId:string,request:ConversationReques
|
|||||||
if(peer){
|
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.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);
|
peer.memories=peer.memories.slice(-100);
|
||||||
for(const [from,to] of [[bot,peer],[peer,bot]]){
|
bondEvidence(state,request.id+':bond',bot,peer,'Shared a confirmed conversation.',1);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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';
|
bot.simulationBand='foreground';
|
||||||
realmEvent(state,'memory',`${bot.name} had a live conversation with ${request.speakerName}.`,bot.id);
|
realmEvent(state,'memory',`${bot.name} had a live conversation with ${request.speakerName}.`,bot.id);
|
||||||
try{
|
try{
|
||||||
@@ -177,6 +174,7 @@ export async function runConversationTick(store:StateStore,db:D1Database,config:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await cleanupConversations(db);
|
await cleanupConversations(db);
|
||||||
|
await communityTick(store,db);
|
||||||
const request=await leaseConversation(db);
|
const request=await leaseConversation(db);
|
||||||
if(!request)return {ok:true,ran:false,reason:'No player conversation waiting'};
|
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 { runGM } from './gm';
|
||||||
import { actionSchema } from './validation';
|
import { actionSchema } from './validation';
|
||||||
import { connectors } from './connectors';
|
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>){
|
export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,payload:Record<string,unknown>){
|
||||||
let s=await store.load();
|
let s=await store.load();
|
||||||
const revision=s.revision;
|
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
|
// Complete world snapshots own presence once available; historical events
|
||||||
// must never bring disconnected residents back online.
|
// must never bring disconnected residents back online.
|
||||||
if(kind==='player.snapshot' && payload.roster===true){
|
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(b.status==='retired')return {matched:true,botId:b.id,retired:true};
|
||||||
|
|
||||||
|
if(kind==='player.snapshot')observePresence(s,b,payload);
|
||||||
b.level=level;
|
b.level=level;
|
||||||
if(zone)b.zone=zone;
|
if(zone)b.zone=zone;
|
||||||
|
|
||||||
@@ -231,6 +234,13 @@ export async function execute(store:StateStore,input:unknown,config:RuntimeConfi
|
|||||||
if(a.type==='gm'){
|
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);
|
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;}
|
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'){
|
}else if(a.type==='director'){
|
||||||
s.director={experience:a.experience,zone:a.zone,playerName:a.playerName,brief:a.brief};
|
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);
|
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 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 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 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 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 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};
|
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 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 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 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 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 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}>;}
|
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 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)});
|
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',[
|
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('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('lifecycle'),botId:z.string(),action:z.enum(['spawn','pause','resume','despawn','retire'])}),
|
||||||
z.object({type:z.literal('think'),botId:z.string()}),
|
z.object({type:z.literal('think'),botId:z.string()}),
|
||||||
|
|||||||
@@ -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,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.
|
||||||
@@ -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}<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')
|
||||||
@@ -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'});
|
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);
|
if(result.status)process.exit(result.status);
|
||||||
mkdirSync('.test-build',{recursive:true});writeFileSync('.test-build/package.json','{"type":"commonjs"}');
|
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,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 assert=require('node:assert/strict');
|
||||||
const queue=require('../.test-build/conversation-store.js');
|
const queue=require('../.test-build/conversation-store.js');
|
||||||
const ale=require('../.test-build/ale-store.js');
|
const ale=require('../.test-build/ale-store.js');
|
||||||
|
require('../.test-build/community.js').communityTick=async()=>{};
|
||||||
const {runConversationTick}=require('../.test-build/conversation.js');
|
const {runConversationTick}=require('../.test-build/conversation.js');
|
||||||
|
|
||||||
test('conversation failure, queued delivery, confirmed memory and replay safety',async()=>{
|
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,{},{});
|
await runConversationTick(store,{},{});
|
||||||
assert.equal(a.memories.length,1);assert.equal(b.memories.length,1);
|
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].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,{},{});
|
await runConversationTick(store,{},{});
|
||||||
assert.equal(a.memories.length,1);assert.equal(b.memories.length,1);assert.equal(followups,1);
|
assert.equal(a.memories.length,1);assert.equal(b.memories.length,1);assert.equal(followups,1);
|
||||||
}finally{global.fetch=originalFetch;}
|
}finally{global.fetch=originalFetch;}
|
||||||
|
|||||||
Reference in New Issue
Block a user