Add nightly realm reports and observed bot activity explanations
Core tests / core (push) Successful in 59s

This commit is contained in:
Codex
2026-09-22 20:46:02 -04:00
parent 64fb329b2e
commit 9d586f4c44
13 changed files with 183 additions and 6 deletions
+3 -1
View File
@@ -114,7 +114,7 @@ local function player_payload(player)
healthPct=player:GetHealthPct(), healthPct=player:GetHealthPct(),
observedAt=os.time(),team=player:GetTeam(),x=player:GetX(),y=player:GetY(), observedAt=os.time(),team=player:GetTeam(),x=player:GetX(),y=player:GetY(),
guildId=player:GetGuildId(),guildName=player:GetGuildName() or "",guildRank=player:GetGuildRank(), guildId=player:GetGuildId(),guildName=player:GetGuildName() or "",guildRank=player:GetGuildRank(),
inCombat=player:IsInCombat(), inCombat=player:IsInCombat(),casting=player:IsCasting(),taxi=player:IsTaxi(),movementType=player:GetMovementType(),
isBot=isBot isBot=isBot
} }
end end
@@ -143,6 +143,8 @@ end
local function world_payload(player) local function world_payload(player)
local p=player_payload(player) local p=player_payload(player)
local ai=player:IsPlayerbot() and player:GetBotAI() or nil
p.humanControlled=ai and ai:HasGameClientMaster() or false
local g=player:GetGroup() local g=player:GetGroup()
p.groupId=g and tostring(g:GetGUID()) or "" p.groupId=g and tostring(g:GetGUID()) or ""
local members={} local members={}
+51
View File
@@ -0,0 +1,51 @@
import {env} from 'cloudflare:workers';
import {z} from 'zod';
import {D1Store} from '@/core/store';
import type {RuntimeConfig} from '@/core/types';
import {observeInsights,activityReason,reportWindow,summarize,DAY,type Cursor,type Evidence} from '@/core/realm-insights';
const recovery=z.object({id:z.number().int().positive(),guid:z.number().int().positive(),name:z.string().max(64),at:z.number().finite(),detail:z.string().max(255)});
async function ensure(){await env.DB!.batch([
env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_cursor (id INTEGER PRIMARY KEY,payload TEXT)'),
env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_evidence (id TEXT PRIMARY KEY,at INTEGER,payload TEXT)'),
env.DB!.prepare('CREATE INDEX IF NOT EXISTS insights_evidence_at ON insights_evidence(at)'),
env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_samples (at INTEGER PRIMARY KEY)'),
env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_reports (start INTEGER PRIMARY KEY,payload TEXT)'),
env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_recoveries (id INTEGER PRIMARY KEY,payload TEXT)'),
env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_clock (id INTEGER PRIMARY KEY,at INTEGER)'),
env.DB!.prepare('INSERT OR IGNORE INTO insights_clock VALUES (1,0)')]);}
async function report(start:number,end:number,startedAt:number){
const rows=await env.DB!.prepare('SELECT payload FROM insights_evidence WHERE at>=? AND at<? ORDER BY at').bind(start,end).all<{payload:string}>();
const samples=await env.DB!.prepare('SELECT COUNT(*) AS n FROM insights_samples WHERE at>=? AND at<?').bind(start,end).first<{n:number}>();
return summarize(rows.results.map(r=>JSON.parse(r.payload)),start,end,startedAt,samples?.n??0);
}
export async function GET(){
await ensure();const now=Math.floor(Date.now()/1000),s=await new D1Store().load();
const row=await env.DB!.prepare('SELECT payload FROM insights_cursor WHERE id=1').first<{payload:string}>();const cursor:Cursor|undefined=row?JSON.parse(row.payload):undefined;
const history=await env.DB!.prepare('SELECT payload FROM insights_reports ORDER BY start DESC LIMIT 30').all<{payload:string}>();
const recoveries=await env.DB!.prepare('SELECT payload FROM insights_recoveries ORDER BY id DESC LIMIT 100').all<{payload:string}>();const attempts=recoveries.results.map(r=>JSON.parse(r.payload));
return Response.json({collectedAt:cursor?.at??null,startedAt:cursor?.startedAt??null,nextReportAt:reportWindow(now)+DAY,current:await report(reportWindow(now),now+1,cursor?.startedAt??now),reports:history.results.map(r=>JSON.parse(r.payload)),activity:s.bots.filter(b=>b.autonomy?.managed&&b.status==='online').map(b=>({name:b.name,zone:b.zone,action:b.lastDecision?.action??'unknown',observedAt:b.world?.at??null,stationarySince:b.world?.stationarySince??null,lastProgressAt:cursor?.bots[b.id]?.lastProgressAt??null,...activityReason(b,now),recoveries:attempts.filter(r=>r.guid===b.autonomy?.characterGuid).slice(0,3).map(({at,detail})=>({at,detail}))}))},{headers:{'Cache-Control':'no-store'}});
}
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>50000)throw new Error('Payload too large');const body=z.object({at:z.number().finite(),recoveries:z.array(recovery).max(100)}).strict().parse(JSON.parse(raw));
const now=Math.floor(Date.now()/1000);if(Math.abs(now-body.at)>180)throw new Error('Stale sample');await ensure();
const gate=await env.DB!.prepare('UPDATE insights_clock SET at=? WHERE id=1 AND at<?').bind(now,now-40).run();if(!gate.meta.changes)return Response.json({ok:true,skipped:true});
const previous=await env.DB!.prepare('SELECT payload FROM insights_cursor WHERE id=1').first<{payload:string}>();const old:Cursor|undefined=previous?JSON.parse(previous.payload):undefined;
const {cursor,events}=observeInsights(await new D1Store().load(),old,now);
const statements=events.map(e=>env.DB!.prepare('INSERT OR IGNORE INTO insights_evidence VALUES (?,?,?)').bind(e.id,e.at,JSON.stringify(e)));
for(const r of body.recoveries){
if(r.at>now+60)continue;
statements.push(env.DB!.prepare('INSERT OR IGNORE INTO insights_recoveries VALUES (?,?)').bind(r.id,JSON.stringify(r)));
if(r.at>=cursor.startedAt){const e:Evidence={id:'recovery:'+r.id,at:r.at,kind:'recoveries',name:r.name,amount:1,detail:'Native recovery attempted'};statements.push(env.DB!.prepare('INSERT OR IGNORE INTO insights_evidence VALUES (?,?,?)').bind(e.id,e.at,JSON.stringify(e)));}
}
statements.push(env.DB!.prepare('INSERT INTO insights_cursor VALUES (1,?) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload').bind(JSON.stringify(cursor)),env.DB!.prepare('INSERT OR IGNORE INTO insights_samples VALUES (?)').bind(now));
await env.DB!.batch(statements);
for(let start=Math.max(reportWindow(cursor.startedAt),reportWindow(now)-30*DAY);start<reportWindow(now);start+=DAY){
if(await env.DB!.prepare('SELECT start FROM insights_reports WHERE start=?').bind(start).first())continue;
await env.DB!.prepare('INSERT OR IGNORE INTO insights_reports VALUES (?,?)').bind(start,JSON.stringify(await report(start,start+DAY,cursor.startedAt))).run();
}
await env.DB!.batch([env.DB!.prepare('DELETE FROM insights_evidence WHERE at<?').bind(now-31*DAY),env.DB!.prepare('DELETE FROM insights_samples WHERE at<?').bind(now-31*DAY),env.DB!.prepare('DELETE FROM insights_reports WHERE start<?').bind(reportWindow(now)-30*DAY),env.DB!.prepare('DELETE FROM insights_recoveries WHERE id NOT IN (SELECT id FROM insights_recoveries ORDER BY id DESC LIMIT 100)')]);
return Response.json({ok:true});
}catch(e){return Response.json({error:e instanceof Error?e.message:'Insight collection failed'},{status:400});}
}
+3 -1
View File
@@ -1,4 +1,5 @@
'use client'; 'use client';
import {RealmInsights} from './realm-insights';
import {CommunityPanel,OperationsPanel} from './community-panel'; 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';
@@ -17,7 +18,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],['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 NAV=[['Dashboard',LayoutDashboard],['World Monitor',Globe2],['Nightly Report',ScrollText],['Bot Activity',Clock3],['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}));
@@ -42,6 +43,7 @@ export default function Home({readOnly=false}:{readOnly?:boolean}){
<div className="page-heading"><div><div className="eyebrow">{page==='GM Console'?'THE WORLD IS YOUR SANDBOX':'AZEROTHAI CONTROL PLANE'}</div><h1>{page==='Dashboard'?'Your realm, alive.':page}</h1><p className="subtitle">{({'Dashboard':'The people, personalities, and stories that make your world.','World Monitor':'Watch Azeroth breathe: population, activity, systems, and the lives unfolding right now.','Realm Director':'Shape the adventure around you. Let your characters bring it to life.','World & Housing':'Homes, routines, simulation bands, and believable population flow.','Bots':'Give every character a purpose, then let their story unfold.','Bot Recipes':'Same model. Different personalities. A world of possibilities.','Memory & Bonds':'A shared history makes the next encounter matter.','Event Stream':'Follow the decisions and changes happening across your realm.','GM Console':'Pull up a chair. Put on the blue robe. Run your world.','Server & Models':'Connect the game engine and the minds behind your characters.'} as any)[page]}</p></div><div className="actions">{readOnly?<Button variant="outline" onClick={()=>void refresh()}><RefreshCw size={15}/>Refresh</Button>:page==='Bot Recipes'?<Button onClick={()=>{setRecipe(null);setRecipeOpen(true)}}><Plus size={16}/>Create recipe</Button>:<><Button variant="outline" disabled={busy} onClick={()=>void refresh()}><RefreshCw size={15}/>Refresh</Button><Button onClick={()=>setCreateOpen(true)}><Plus size={16}/>Create bot</Button></>}</div></div> <div className="page-heading"><div><div className="eyebrow">{page==='GM Console'?'THE WORLD IS YOUR SANDBOX':'AZEROTHAI CONTROL PLANE'}</div><h1>{page==='Dashboard'?'Your realm, alive.':page}</h1><p className="subtitle">{({'Dashboard':'The people, personalities, and stories that make your world.','World Monitor':'Watch Azeroth breathe: population, activity, systems, and the lives unfolding right now.','Realm Director':'Shape the adventure around you. Let your characters bring it to life.','World & Housing':'Homes, routines, simulation bands, and believable population flow.','Bots':'Give every character a purpose, then let their story unfold.','Bot Recipes':'Same model. Different personalities. A world of possibilities.','Memory & Bonds':'A shared history makes the next encounter matter.','Event Stream':'Follow the decisions and changes happening across your realm.','GM Console':'Pull up a chair. Put on the blue robe. Run your world.','Server & Models':'Connect the game engine and the minds behind your characters.'} as any)[page]}</p></div><div className="actions">{readOnly?<Button variant="outline" onClick={()=>void refresh()}><RefreshCw size={15}/>Refresh</Button>:page==='Bot Recipes'?<Button onClick={()=>{setRecipe(null);setRecipeOpen(true)}}><Plus size={16}/>Create recipe</Button>:<><Button variant="outline" disabled={busy} onClick={()=>void refresh()}><RefreshCw size={15}/>Refresh</Button><Button onClick={()=>setCreateOpen(true)}><Plus size={16}/>Create bot</Button></>}</div></div>
{error&&<div className="notice" role="alert">{error} <Button variant="outline" onClick={()=>void refresh()}>Retry</Button></div>} {error&&<div className="notice" role="alert">{error} <Button variant="outline" onClick={()=>void refresh()}>Retry</Button></div>}
{!state?<div className="stats">{[1,2,3,4].map(n=><Skeleton key={n} className="h-32"/>)}</div>:<> {!state?<div className="stats">{[1,2,3,4].map(n=><Skeleton key={n} className="h-32"/>)}</div>:<>
{(page==='Nightly Report'||page==='Bot Activity')&&<RealmInsights mode={page}/>}
{page==='Dashboard'&&<><div className="stats">{[[Users,'Active bots',state.bots.filter(b=>b.status==='online'&&(state.settings.connector==='simulation'||b.autonomy?.managed)).length,'Online in the simulation'],[BookOpen,'Bot recipes',state.recipes.length,'Distinct ways to experience the world'],[Brain,'Memories',state.bots.reduce((n,b)=>n+b.memories.length,0),'Stories carried into future decisions'],[Network,'Relationships',state.bots.reduce((n,b)=>n+b.relationships.length,0),'Directed bonds between characters']].map(([Icon,label,value,note]:any)=><div className="panel stat" key={label}><div className="stat-label">{label}<Icon/></div><div className="stat-value">{value}</div><small>{label==='Active bots'&&state.settings.connector!=='simulation'?'Acknowledged by the game bridge':note}</small></div>)}</div><section className="panel director-strip"><div className="director-content"><Compass className="director-icon"/><div><div className="eyebrow">YOUR NEXT ADVENTURE</div><h2>{state.director.experience}</h2><p>{state.director.zone} · {state.director.brief}</p></div></div><Button variant="outline" onClick={()=>nav('Realm Director')}>{readOnly?'View realm direction':'Shape the experience'} <ArrowRight size={15}/></Button></section><div className="dashboard-grid"><Panel title="Your characters" icon={Users} action={<Button variant="ghost" size="sm" onClick={()=>nav('Bots')}>{readOnly?'View bots':'Manage bots'} <ArrowRight size={14}/></Button>}>{botTable(state.bots.slice(0,5))}</Panel><Panel title="Realm pulse" icon={Radio}><div className="panel-body">{Array.from(new Set(state.bots.filter(b=>b.status==='online').map(b=>b.zone))).map(zone=>{const n=state.bots.filter(b=>b.status==='online'&&b.zone===zone).length;return <div className="zone-row" key={zone}><span className="zone-label">{zone}</span><span className="bar"><i style={{width:`${n/Math.max(1,state.bots.filter(b=>b.status==='online').length)*100}%`}}/></span><span>{n}</span></div>})}<p className="fineprint">Bot locations from the selected connector. Simulation locations are illustrative.</p><div className="engine-box" style={{marginTop:25,marginBottom:0}}><Brain size={23}/><div><h3>{state.settings.provider==='deterministic'?'Personality simulator':state.settings.model}</h3><p>{state.settings.provider==='deterministic'?'No model required. Try a decision from any online bot.':'Local model selected. Each decision includes identity and memories.'}</p></div></div></div></Panel><Panel title="Recent activity" icon={Activity} action={<Button variant="ghost" size="sm" onClick={()=>nav('Event Stream')}>View stream <ArrowRight size={14}/></Button>}>{<Events events={state.events.slice(-5)}/>}</Panel><Panel title="The Game Master’s chair" icon={Shield}><div className="panel-body"><p className="list-muted">Set the scene. Announce a gathering. Move a character to the next chapter of their story.</p><div className="gm-preview"><span>&lt;GM&gt; AzerothAI</span><p>Welcome to your world.</p><code>.announce An adventure awaits...</code></div><Button variant="outline" onClick={()=>nav('GM Console')}>Open GM console <Terminal size={15}/></Button></div></Panel></div></>} {page==='Dashboard'&&<><div className="stats">{[[Users,'Active bots',state.bots.filter(b=>b.status==='online'&&(state.settings.connector==='simulation'||b.autonomy?.managed)).length,'Online in the simulation'],[BookOpen,'Bot recipes',state.recipes.length,'Distinct ways to experience the world'],[Brain,'Memories',state.bots.reduce((n,b)=>n+b.memories.length,0),'Stories carried into future decisions'],[Network,'Relationships',state.bots.reduce((n,b)=>n+b.relationships.length,0),'Directed bonds between characters']].map(([Icon,label,value,note]:any)=><div className="panel stat" key={label}><div className="stat-label">{label}<Icon/></div><div className="stat-value">{value}</div><small>{label==='Active bots'&&state.settings.connector!=='simulation'?'Acknowledged by the game bridge':note}</small></div>)}</div><section className="panel director-strip"><div className="director-content"><Compass className="director-icon"/><div><div className="eyebrow">YOUR NEXT ADVENTURE</div><h2>{state.director.experience}</h2><p>{state.director.zone} · {state.director.brief}</p></div></div><Button variant="outline" onClick={()=>nav('Realm Director')}>{readOnly?'View realm direction':'Shape the experience'} <ArrowRight size={15}/></Button></section><div className="dashboard-grid"><Panel title="Your characters" icon={Users} action={<Button variant="ghost" size="sm" onClick={()=>nav('Bots')}>{readOnly?'View bots':'Manage bots'} <ArrowRight size={14}/></Button>}>{botTable(state.bots.slice(0,5))}</Panel><Panel title="Realm pulse" icon={Radio}><div className="panel-body">{Array.from(new Set(state.bots.filter(b=>b.status==='online').map(b=>b.zone))).map(zone=>{const n=state.bots.filter(b=>b.status==='online'&&b.zone===zone).length;return <div className="zone-row" key={zone}><span className="zone-label">{zone}</span><span className="bar"><i style={{width:`${n/Math.max(1,state.bots.filter(b=>b.status==='online').length)*100}%`}}/></span><span>{n}</span></div>})}<p className="fineprint">Bot locations from the selected connector. Simulation locations are illustrative.</p><div className="engine-box" style={{marginTop:25,marginBottom:0}}><Brain size={23}/><div><h3>{state.settings.provider==='deterministic'?'Personality simulator':state.settings.model}</h3><p>{state.settings.provider==='deterministic'?'No model required. Try a decision from any online bot.':'Local model selected. Each decision includes identity and memories.'}</p></div></div></div></Panel><Panel title="Recent activity" icon={Activity} action={<Button variant="ghost" size="sm" onClick={()=>nav('Event Stream')}>View stream <ArrowRight size={14}/></Button>}>{<Events events={state.events.slice(-5)}/>}</Panel><Panel title="The Game Master’s chair" icon={Shield}><div className="panel-body"><p className="list-muted">Set the scene. Announce a gathering. Move a character to the next chapter of their story.</p><div className="gm-preview"><span>&lt;GM&gt; AzerothAI</span><p>Welcome to your world.</p><code>.announce An adventure awaits...</code></div><Button variant="outline" onClick={()=>nav('GM Console')}>Open GM console <Terminal size={15}/></Button></div></Panel></div></>}
{page==='World Monitor'&&<WorldMonitor state={state} monitor={monitor} capabilities={capabilities} openBot={setBotId}/>} {page==='World Monitor'&&<WorldMonitor state={state} monitor={monitor} capabilities={capabilities} openBot={setBotId}/>}
{page==='Bots'&&<><div className="toolbar"><Input aria-label="Search bots" placeholder="Search name, class, or location…" value={query} onChange={e=>setQuery(e.target.value)}/><Choice label="Bot status" value={filter} onChange={setFilter} items={options(['all','online','paused','offline','retired'])}/><small>{visible.length} characters</small></div><Panel title="Bot lifecycle" icon={Users}>{visible.length?botTable(visible):<div className="empty-note">No matching bots. Create a character or adjust your filters.</div>}</Panel></>} {page==='Bots'&&<><div className="toolbar"><Input aria-label="Search bots" placeholder="Search name, class, or location…" value={query} onChange={e=>setQuery(e.target.value)}/><Choice label="Bot status" value={filter} onChange={setFilter} items={options(['all','online','paused','offline','retired'])}/><small>{visible.length} characters</small></div><Panel title="Bot lifecycle" icon={Users}>{visible.length?botTable(visible):<div className="empty-note">No matching bots. Create a character or adjust your filters.</div>}</Panel></>}
+23
View File
@@ -0,0 +1,23 @@
'use client';
import {useEffect,useState} from 'react';
import {Input} from '@/components/ui/input';
import type {summarize} from '@/core/realm-insights';
type Report=ReturnType<typeof summarize>;
type Activity={name:string;zone:string;action:string;label:string;detail:string;observedAt:number|null;lastProgressAt:number|null;recoveries:{at:number;detail:string}[]};
type Insights={collectedAt:number|null;startedAt:number|null;nextReportAt:number;current:Report;reports:Report[];activity:Activity[]};
const stamp=(at:number|null)=>at?new Date(at*1000).toLocaleString():'Not yet observed';
const metrics:Record<string,string>={levels:'Levels gained',deaths:'Verified deaths',arrivals:'First arrivals',conversations:'Bot exchanges',bonds:'New positive bonds',parties:'Party invitations',recruits:'Guild admissions',guild:'Guild goal progress',recoveries:'Recovery attempts'};
export function RealmInsights({mode}:{mode:string}){
const [data,setData]=useState<Insights|null>(null),[error,setError]=useState(''),[selected,setSelected]=useState('current'),[query,setQuery]=useState(''),[attention,setAttention]=useState(false);
useEffect(()=>{let stopped=false;const poll=async()=>{try{const r=await fetch('/api/realm-insights');if(!r.ok)throw new Error('Insights temporarily unavailable');const d=await r.json() as Insights;if(!stopped){setData(d);setError('');}}catch(e){if(!stopped)setError(e instanceof Error?e.message:'Unable to load insights');}};void poll();const timer=setInterval(poll,15000);return()=>{stopped=true;clearInterval(timer);};},[]);
if(!data)return <div className="empty-note" role="status">{error||'Loading realm observations…'}</div>;
const report=selected==='current'?data.current:data.reports.find(r=>String(r.start)===selected)??data.current;
const rows=data.activity.filter(b=>`${b.name} ${b.zone} ${b.label}`.toLowerCase().includes(query.toLowerCase())&&(!attention||['Possible stall','Telemetry delayed'].includes(b.label)));
return <><p className="fineprint">Last collection: {stamp(data.collectedAt)} · Refreshes every 15 seconds.</p>{error&&<p role="alert" className="notice">{error}; showing the last available observations.</p>}{(!data.collectedAt||Date.now()/1000-data.collectedAt>180)&&<p role="status" className="notice">Collection is pending or delayed. Report totals may be incomplete.</p>}
{mode==='Nightly Report'?<>
<section className="panel"><div className="panel-body"><label className="field">Report period<select value={selected} onChange={e=>setSelected(e.target.value)}><option value="current">Today · in progress</option>{data.reports.map(r=><option key={r.start} value={r.start}>{stamp(r.start)} — {stamp(r.end)}</option>)}</select></label><p>{stamp(report.start)} — {selected==='current'?'now':stamp(report.end)}</p><p className="fineprint">Reports close daily at 06:00 UTC. Next close: {stamp(data.nextReportAt)}. Reports are kept for 30 days.</p><p className="fineprint">{report.partial?'Partial period · ':''}Observations since {stamp(report.observedSince)} · {report.samples} minute samples. Gaps can miss activity; these are observed changes, not a complete game log.</p></div></section>
<div className="stats" style={{marginTop:20}}>{Object.entries(metrics).map(([key,label])=><div className="panel stat" key={key}><div className="stat-label">{label}</div><div className="stat-value">{report.totals[key]??0}</div></div>)}</div>
<section className="panel"><div className="panel-head"><h2>Realm highlights</h2></div><div className="panel-body">{report.highlights.length?report.highlights.map(e=><div className="memory" key={e.id}><strong>{e.name}</strong><p>{e.detail}</p><small>{stamp(e.at)}</small></div>):<p className="empty-note">No changes recorded in this period yet.</p>}<p className="fineprint">Exchanges count confirmed bot replies, excluding synthetic openers and private player messages. Positive bonds need not be mutual friendships. Arrivals mean first observed online, not necessarily newly created. Recovery attempts do not prove recovery succeeded.</p></div></section>
</>:<><section className="panel"><div className="panel-body"><Input aria-label="Search bot activity" placeholder="Search by name, zone, or activity" value={query} onChange={e=>setQuery(e.target.value)}/><label style={{display:'flex',gap:8,marginTop:16}}><input type="checkbox" checked={attention} onChange={e=>setAttention(e.target.checked)}/>Show possible stalls and delayed observations</label><p className="fineprint">{rows.length} of {data.activity.length} online residents. Progress means observed movement, level gain, or health change. Intentions describe plans, not completed game actions.</p></div></section><div style={{display:'grid',gridTemplateColumns:'repeat(auto-fit,minmax(min(100%,320px),1fr))',gap:16,marginTop:20}}>{rows.map(b=><section className="panel" key={b.name}><div className="panel-head"><h2>{b.name}</h2><span className="tag">{b.label}</span></div><div className="panel-body"><p>{b.zone} · Intent: {b.action}</p><p style={{marginTop:12}}>{b.detail}</p><p className="fineprint">Game observation: {stamp(b.observedAt)}<br/>Last observed progress: {stamp(b.lastProgressAt)}</p><details><summary>Recent recovery attempts ({b.recoveries.length})</summary>{b.recoveries.length?b.recoveries.map((r,i)=><p className="fineprint" key={i}>{stamp(r.at)} · {r.detail}</p>):<p className="fineprint">No retained attempts. Recovery requires ten minutes without native progress, safe conditions, and an expired 30-minute cooldown.</p>}</details></div></section>)}</div>{!rows.length&&<p className="empty-note">No residents match this filter.</p>}</>}
</>;
}
+1 -1
View File
@@ -24,7 +24,7 @@ export function observePresence(s:State,b:Bot,p:Record<string,unknown>){
if(typeof p.team!=='number'||typeof p.x!=='number'||typeof p.y!=='number')return; if(typeof p.team!=='number'||typeof p.x!=='number'||typeof p.y!=='number')return;
const old=b.world; const old=b.world;
const stationary=old&&old.mapId===p.mapId&&Math.hypot(old.x-p.x,old.y-p.y)<3; 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}; b.world={casting:p.casting===true,taxi:p.taxi===true,humanControlled:p.humanControlled===true,movementType:typeof p.movementType==='number'?p.movementType:undefined,healthPct:typeof p.healthPct==='number'?p.healthPct:undefined,at,team:p.team,guildId:Number(p.guildId)||0,guildName:String(p.guildName||''),guildRank:Number(p.guildRank)||0,groupId:String(p.groupId||''),groupNames:String(p.groupNames||'').split(',').filter(Boolean),nearby:String(p.nearbyBots||'').split(',').filter(Boolean),inCombat:p.inCombat===true,x:p.x,y:p.y,mapId:Number(p.mapId)||0,stationarySince:stationary?(old.stationarySince??old.at):at};
const c=community(s); 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(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){ if(old&&old.mapId!==b.world.mapId&&b.world.guildId){
+52
View File
@@ -0,0 +1,52 @@
import type {Bot,State} from './types';
export const DAY=86400,BOUNDARY=6*3600;
export const reportWindow=(at:number)=>Math.floor((at-BOUNDARY)/DAY)*DAY+BOUNDARY;
export type Evidence={id:string;at:number;kind:string;name:string;amount:number;detail:string};
export type Progress={level:number;status:string;seenOnline:boolean;x?:number;y?:number;map?:number;health?:number;lastProgressAt?:number};
export type Cursor={at:number;startedAt:number;bots:Record<string,Progress>;bonds:string[];guilds:Record<string,{goal:string;progress:number}>;social:string[]};
export function observeInsights(s:State,previous:Cursor|undefined,now:number){
const events:Evidence[]=[];const cursor:Cursor={at:now,startedAt:previous?.startedAt??now,bots:{},bonds:[],guilds:{},social:[]};
const add=(id:string,kind:string,name:string,amount:number,detail:string,at=now)=>events.push({id,at,kind,name,amount,detail});
const managed=s.bots.filter(b=>b.autonomy?.managed),ids=new Set(managed.map(b=>b.id));
for(const b of managed){
const old=previous?.bots[b.id],w=b.world;
const fresh=!!w&&now-w.at<120;
const moved=fresh&&old?.x!==undefined&&(old.map!==w!.mapId||Math.hypot(w!.x-old.x,w!.y-(old.y??w!.y))>=3);
const advanced=!!old&&(b.level>old.level||(fresh&&old.health!==undefined&&w!.healthPct!==undefined&&w!.healthPct!==old.health));
cursor.bots[b.id]={level:b.level,status:b.status,seenOnline:!!old?.seenOnline||b.status==='online',x:fresh?w!.x:old?.x,y:fresh?w!.y:old?.y,map:fresh?w!.mapId:old?.map,health:fresh?w!.healthPct:old?.health,lastProgressAt:moved||advanced?now:old?.lastProgressAt};
if(previous){
if(old&&b.level>old.level)add(`level:${b.id}:${b.level}`,'levels',b.name,b.level-old.level,`Level ${old.level} → ${b.level}`);
if(b.status==='dead'&&old?.status!=='dead'&&b.death?.verified)add(`death:${b.id}`,'deaths',b.name,1,`Level ${b.death.level} · ${b.death.zone}`);
if(b.status==='online'&&!old?.seenOnline)add(`arrival:${b.id}`,'arrivals',b.name,1,`First observed online at level ${b.level}`);
}
for(const r of b.relationships){if(r.affinity<=0||!ids.has(r.targetId))continue;const key=[b.id,r.targetId].sort().join(':');if(cursor.bonds.includes(key))continue;cursor.bonds.push(key);if(previous&&!previous.bonds.includes(key))add('bond:'+key,'bonds',`${b.name} & ${managed.find(x=>x.id===r.targetId)?.name}`,1,'First observed positive bond');}
}
for(const g of s.community?.guilds??[]){const old=previous?.guilds[g.id];cursor.guilds[g.id]={goal:g.goal,progress:g.progress};if(old&&old.goal===g.goal&&g.progress>old.progress)add(`guild:${g.id}:${g.goal}:${g.progress}`,'guild',g.name,g.progress-old.progress,`${g.goal} goal: ${g.progress} / ${g.target}`);}
for(const e of s.community?.feed??[]){
if(e.status==='confirmed')cursor.social.push(e.id);
if(!previous||previous.social.includes(e.id)||e.status!=='confirmed'||e.id.endsWith(':proposal'))continue;
const at=Date.parse(e.at)/1000;if(!Number.isFinite(at)||at<previous.startedAt||at>now+60)continue;
if(e.kind==='conversation'&&e.id.startsWith('social-')&&e.id.endsWith(':reply'))add('social:'+e.id,'conversations',e.names.join(' & '),1,'Completed bot-to-bot exchange',at);
if(e.kind==='party'||e.kind==='recruit')add('social:'+e.id,e.kind==='party'?'parties':'recruits',e.names.join(' & '),1,e.kind==='party'?'Confirmed party invitation':'Confirmed guild admission',at);
}
return {cursor,events};
}
export function activityReason(b:Bot,now:number){
const w=b.world,action=b.lastDecision?.action;
if(b.status!=='online')return {label:b.status,detail:'This resident is not currently online.'};
if(!w||now-w.at>120)return {label:'Telemetry delayed',detail:'Current activity cannot be confirmed from stale observations.'};
if(w.inCombat)return {label:'In combat',detail:'The game reports active combat. Recovery is skipped.'};
if(w.casting)return {label:'Casting',detail:'The game reports a spell cast. Recovery is skipped.'};
if(w.taxi)return {label:'Traveling by taxi',detail:'The game reports taxi travel. Recovery is skipped.'};
if(w.humanControlled)return {label:'Following player control',detail:'A human master controls this bot. Recovery is skipped.'};
const idle=now-(w.stationarySince??w.at);
if(idle<90)return {label:'Recently moving',detail:`Movement observed recently; intended activity: ${action??'not recorded'}.`};
if(action==='rest'||action==='socialize'||action==='trade')return {label:`Intends to ${action}`,detail:'Stationary activity may be intentional. This is an AI intention, not proof of the action completing; recovery is skipped.'};
if(idle>=600&&(action==='quest'||action==='explore'))return {label:'Possible stall',detail:w.movementType===7?'Flight controller remains active; native recovery checks whether it is stale.':'No recent movement while intending to travel. The native guard checks progress, safety and cooldown before recovery.'};
return {label:w.groupId?'Grouped / stationary':'Briefly stationary',detail:w.groupId?'May be waiting for companions; waiting is inferred, not confirmed.':'May be choosing a target or pausing between actions. No stall is established.'};
}
export function summarize(events:Evidence[],start:number,end:number,startedAt:number,samples:number){
const totals:Record<string,number>={levels:0,deaths:0,arrivals:0,conversations:0,bonds:0,parties:0,recruits:0,guild:0,recoveries:0};
for(const e of events)totals[e.kind]=(totals[e.kind]??0)+e.amount;
return {start,end,observedSince:Math.max(start,startedAt),partial:startedAt>start,samples,totals,highlights:events.filter(e=>['levels','deaths','arrivals','guild','bonds'].includes(e.kind)).slice(-40).reverse()};
}
+1 -1
View File
@@ -10,7 +10,7 @@ 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 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 WorldPresence = {casting?:boolean;taxi?:boolean;humanControlled?:boolean;movementType?:number;healthPct?:number;at:number;team:number;guildId:number;guildName:string;guildRank:number;groupId:string;groupNames:string[];nearby:string[];inCombat:boolean;x:number;y:number;mapId:number;stationarySince?:number};
export type SocialEntry = {id:string;at:string;kind:string;names:string[];text:string;affinityDelta?:number;status?:string}; export type 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 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 Community = {feed:SocialEntry[];evidence:string[];guilds:GuildGoal[];partiesEnabled:boolean;guildsEnabled:boolean};
+11
View File
@@ -0,0 +1,11 @@
# Realm reports and activity explanations
Nightly Report and Bot Activity are available in both the admin and read-only visitor monitor. Visitor access is unchanged.
The existing Ubuntu operations timer samples realm state once a minute through authenticated `POST /api/realm-insights`. The first sample establishes a baseline: existing deaths, levels and relationships are not presented as new events. Reports close at 06:00 UTC, finalized on the first successful sample after that boundary. The first period is partial. Thirty completed reports and 31 days of evidence/samples are retained in D1, included in existing verified realm backups. The latest 100 native recovery attempts are retained separately.
Reports count observed level gains, verified deaths, first online appearances, confirmed bot reply exchanges, positive relationship pairs, party invitations, guild admissions and same-goal guild progress. First appearances need not mean newly created characters, invitations do not prove a party remains together, and positive bonds do not prove reciprocal friendship. Private player conversations and synthetic openers are excluded. Collection gaps may miss events. A report includes sample coverage; no historical event reconstruction is attempted.
Activity explanations prioritize fresh native combat, casting, taxi travel and human control. AI decisions are labeled intentions. Stationary explanations and possible stalls are inferences, not diagnoses. Last observed progress uses movement, level gain or health change. Recovery attempts are shown without claiming success; the existing native guard still enforces its independent ten-minute progress window, safety exclusions and 30-minute cooldown.
`GET /api/realm-insights` returns only public summaries and managed bot activity. The visitor gateway permits that fixed GET and rejects every mutation. Collection POST requires AUTONOMY_TOKEN. No credentials, native account IDs, memory text, private chat or raw command payloads are returned.
+11
View File
@@ -78,6 +78,17 @@ try:
count=recovery.publish_recovery_intents(current,cfg) count=recovery.publish_recovery_intents(current,cfg)
maintenance.append(f'Recovery intentions refreshed for {count} residents') maintenance.append(f'Recovery intentions refreshed for {count} residents')
except Exception as e:maintenance.append('Recovery check needs attention: '+type(e).__name__) except Exception as e:maintenance.append('Recovery check needs attention: '+type(e).__name__)
try:
recovery_rows=[]
try:
for line in sql('SELECT id,guid,name,UNIX_TIMESTAMP(at),detail FROM acore_auth.azerothai_recovery_log ORDER BY id DESC LIMIT 100').splitlines():
fields=line.split('\t',4)
recovery_rows.append(dict(id=int(fields[0]),guid=int(fields[1]),name=fields[2],at=int(fields[3]),detail=fields[4]))
except Exception as e:maintenance.append('Recovery history unavailable: '+type(e).__name__)
request=urllib.request.Request(base+'/api/realm-insights',data=json.dumps({'at':time.time(),'recoveries':recovery_rows}).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+cfg['AUTONOMY_TOKEN']})
with urllib.request.urlopen(request,timeout=20) as response:assert response.status==200
maintenance.append('Realm report sampled')
except Exception as e:maintenance.append('Realm report needs attention: '+type(e).__name__)
try: try:
backup_status=json.loads((lab/'backups/latest-status.json').read_text()) backup_status=json.loads((lab/'backups/latest-status.json').read_text())
maintenance.append(('Backup restored and verified: ' if backup_status.get('ok') else 'BACKUP NEEDS ATTENTION: ')+str(backup_status.get('at','unknown'))) maintenance.append(('Backup restored and verified: ' if backup_status.get('ok') else 'BACKUP NEEDS ATTENTION: ')+str(backup_status.get('at','unknown')))
+6
View File
@@ -195,6 +195,12 @@ class Handler(BaseHTTPRequestHandler):
at = data['rosterAt'] if data else 0 at = data['rosterAt'] if data else 0
from datetime import datetime, timezone from datetime import datetime, timezone
self.reply(200, json.dumps({'ok':bool(data),'ale':{'lastEventAt':datetime.fromtimestamp(at,timezone.utc).isoformat() if at else None,'activeRecently':bool(data and not data['stale'])},'events':[],'commands':{}}).encode()) self.reply(200, json.dumps({'ok':bool(data),'ale':{'lastEventAt':datetime.fromtimestamp(at,timezone.utc).isoformat() if at else None,'activeRecently':bool(data and not data['stale'])},'events':[],'commands':{}}).encode())
elif path == '/api/realm-insights':
try:
# Fixed read-only endpoint: public aggregate facts and bot activity only.
self.reply(200, local_get('/api/realm-insights'))
except Exception:
self.reply(503,b'{"error":"Realm insights unavailable"}')
elif path == '/api/operations': elif path == '/api/operations':
try: try:
health = json.loads(local_get('/api/operations')) health = json.loads(local_get('/api/operations'))
+2 -2
View File
@@ -1,6 +1,6 @@
import {spawnSync} from 'node:child_process'; import {spawnSync} from 'node:child_process';
import {writeFileSync,mkdirSync} from 'node:fs'; 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/realm-insights.ts','core/service.ts','core/seed.ts','core/ale.ts','core/conversation.ts','core/autonomy.ts','--types','@cloudflare/workers-types,node','--outDir','.test-build','--module','commonjs','--target','es2022','--moduleResolution','node','--esModuleInterop','--skipLibCheck','--strict'],{stdio:'inherit'});
if(result.status)process.exit(result.status); 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','tests/community.test.cjs'],{stdio:'inherit'}).status??1); process.exit(spawnSync(process.execPath,['--test','tests/realm-insights.test.cjs','tests/core.test.cjs','tests/hardcore.test.cjs','tests/living-world.test.cjs','tests/ale.test.cjs','tests/brains.test.cjs','tests/conversation.test.cjs','tests/autonomy.test.cjs','tests/community.test.cjs'],{stdio:'inherit'}).status??1);
+8
View File
@@ -56,6 +56,14 @@ class SpectatorTests(unittest.TestCase):
with patch.object(m.SNAPSHOT,'get',return_value={'state':{'bots':[]}}): with patch.object(m.SNAPSHOT,'get',return_value={'state':{'bots':[]}}):
with urllib.request.urlopen(base+'/api/state') as response: with urllib.request.urlopen(base+'/api/state') as response:
self.assertEqual(json.load(response),{'bots':[]}) self.assertEqual(json.load(response),{'bots':[]})
with patch.object(m,'local_get',return_value=b'{"reports":[]}') as upstream:
with urllib.request.urlopen(base+'/api/realm-insights') as response:
self.assertEqual(json.load(response),{'reports':[]})
upstream.assert_called_once_with('/api/realm-insights')
with self.assertRaises(urllib.error.HTTPError) as error:
urllib.request.urlopen(urllib.request.Request(base+'/api/realm-insights',method='POST',data=b'{}'))
self.assertEqual(error.exception.code,405)
error.exception.close()
for method in ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'TRACE']: for method in ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'TRACE']:
with self.assertRaises(urllib.error.HTTPError) as error: with self.assertRaises(urllib.error.HTTPError) as error:
urllib.request.urlopen(urllib.request.Request(base + '/api/actions', method=method, data=b'{}')) urllib.request.urlopen(urllib.request.Request(base + '/api/actions', method=method, data=b'{}'))
+11
View File
@@ -0,0 +1,11 @@
const {test}=require('node:test');const assert=require('node:assert/strict');
const {observeInsights,activityReason,reportWindow,summarize}=require('../.test-build/realm-insights.js');
const now=200000;
function bot(id='A'){return {id,name:id,status:'online',level:1,relationships:[],autonomy:{managed:true},world:{at:now,x:0,y:0,mapId:0,healthPct:100,stationarySince:now-700},lastDecision:{action:'quest'}};}
function state(){return {bots:[bot(),bot('B')],community:{guilds:[],feed:[]}};}
test('insight baseline does not invent historical events; detects changed facts once',()=>{const s=state();let a=observeInsights(s,undefined,now);assert.equal(a.events.length,0);s.bots[0].level=3;s.bots[1].status='dead';s.bots[1].death={verified:true,level:1,zone:'Forest'};s.bots.push(bot('C'));const b=observeInsights(s,a.cursor,now+60);assert.deepEqual(b.events.map(e=>[e.kind,e.amount]),[['levels',2],['deaths',1],['arrivals',1]]);assert.equal(observeInsights(s,b.cursor,now+120).events.length,0);});
test('report counts confirmed bot replies, unique positive pairs and same-goal guild progress',()=>{const s=state();s.community.guilds=[{id:'g',name:'Guild',goal:'Explore',progress:1,target:10}];const a=observeInsights(s,undefined,now);s.community.guilds[0].progress=4;s.bots[0].relationships=[{targetId:'B',affinity:5}];s.bots[1].relationships=[{targetId:'A',affinity:5}];s.community.feed=['social-1:reply','social-1:opener','whisper-1','social-2:reply'].map((id,i)=>({id,kind:'conversation',at:new Date((now+60)*1000).toISOString(),status:i===3?'pending':'confirmed',names:['A','B']}));const b=observeInsights(s,a.cursor,now+60);assert.equal(b.events.filter(e=>e.kind==='conversations').length,1);assert.equal(b.events.filter(e=>e.kind==='bonds').length,1);assert.equal(b.events.find(e=>e.kind==='guild').amount,3);assert.equal(observeInsights(s,b.cursor,now+120).events.length,0);});
test('report closes at six UTC, records partial baseline and observed totals',()=>{assert.equal(reportWindow(21600),21600);assert.equal(reportWindow(21599),21600-86400);const r=summarize([{kind:'levels',amount:3}],21600,108000,22000,5);assert.equal(r.partial,true);assert.equal(r.observedSince,22000);assert.equal(r.totals.levels,3);assert.equal(r.samples,5);});
test('activity distinguishes stale data and guarded actions from inferred stalls',()=>{const b=bot();assert.equal(activityReason(b,now).label,'Possible stall');b.world.inCombat=true;assert.equal(activityReason(b,now).label,'In combat');b.world.at=now-121;assert.equal(activityReason(b,now).label,'Telemetry delayed');b.world.at=now;b.world.inCombat=false;b.world.casting=true;assert.equal(activityReason(b,now).label,'Casting');b.world.casting=false;b.lastDecision.action='rest';assert.equal(activityReason(b,now).label,'Intends to rest');});
test('pending social events count once after native confirmation',()=>{const s=state();s.community.feed=[{id:'social-1:reply',kind:'conversation',status:'pending',at:new Date(now*1000).toISOString(),names:['A','B']}];const a=observeInsights(s,undefined,now);s.community.feed[0].status='confirmed';const b=observeInsights(s,a.cursor,now+60);assert.equal(b.events.length,1);assert.equal(observeInsights(s,b.cursor,now+120).events.length,0);});