Add native gameplay outcomes, recovery verification, and realm diagnostics
Core tests / core (push) Successful in 40s

This commit is contained in:
Codex
2026-09-22 21:18:32 -04:00
parent 9d586f4c44
commit 290926f33f
18 changed files with 264 additions and 21 deletions
+36
View File
@@ -0,0 +1,36 @@
-- Bounded native gameplay counters. Flush once a minute, never per loot item.
AuthDBExecute([[CREATE TABLE IF NOT EXISTS azerothai_outcomes (
guid INT UNSIGNED PRIMARY KEY,quests INT UNSIGNED NOT NULL DEFAULT 0,
abandoned INT UNSIGNED NOT NULL DEFAULT 0,loot_events INT UNSIGNED NOT NULL DEFAULT 0,
loot_items INT UNSIGNED NOT NULL DEFAULT 0,party_kills INT UNSIGNED NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)]])
local pending={}
local function add(p,key,amount)
if not p:IsPlayerbot() then return end
local guid=p:GetGUIDLow()
local row=pending[guid] or {quests=0,abandoned=0,loot_events=0,loot_items=0,party_kills=0}
row[key]=row[key]+(amount or 1);pending[guid]=row
end
RegisterPlayerEvent(54,function(_,p) add(p,'quests') end)
RegisterPlayerEvent(38,function(_,p) add(p,'abandoned') end)
RegisterPlayerEvent(32,function(_,p,item,count) add(p,'loot_events');add(p,'loot_items',count) end)
RegisterPlayerEvent(7,function(_,p,killed)
if not p:IsPlayerbot() then return end
local g=p:GetGroup();if not g then return end
for _,other in pairs(g:GetMembers()) do
if other~=p and other:IsPlayerbot() and not other:IsDead() and other:IsInCombat()
and other:GetVictim() and other:GetVictim():GetGUID()==killed:GetGUID() and p:GetDistance(other)<=35 then
add(p,'party_kills');break
end
end
end)
local function flush()
for guid,r in pairs(pending) do
AuthDBExecute('INSERT INTO azerothai_outcomes(guid,quests,abandoned,loot_events,loot_items,party_kills) VALUES ('..guid..','..r.quests..','..r.abandoned..','..r.loot_events..','..r.loot_items..','..r.party_kills..') ON DUPLICATE KEY UPDATE quests=quests+VALUES(quests),abandoned=abandoned+VALUES(abandoned),loot_events=loot_events+VALUES(loot_events),loot_items=loot_items+VALUES(loot_items),party_kills=party_kills+VALUES(party_kills),updated_at=NOW()')
end
pending={}
end
CreateLuaEvent(flush,60000,0)
-- Native profiler JSON is generated every five minutes; enabling it is an operator setting.
CreateLuaEvent(function()RunCommand('playerbots pmon tick')end,300000,0)
PrintInfo('[AzerothAI] Gameplay counters enabled; one-minute flush, five-minute profiler export')
+22 -1
View File
@@ -1,6 +1,6 @@
-- Conservative recovery for managed residents with fresh quest/explore intent. -- Conservative recovery for managed residents with fresh quest/explore intent.
-- Native position/progress is sampled independently of dashboard summaries. -- Native position/progress is sampled independently of dashboard summaries.
local intents, observations = {}, {} local intents, observations, pendingResults = {}, {}, {}
local LIMIT = 600 local LIMIT = 600
local COOLDOWN = 1800 local COOLDOWN = 1800
local function sqltext(s) local function sqltext(s)
@@ -10,6 +10,25 @@ AuthDBExecute([[CREATE TABLE IF NOT EXISTS azerothai_recovery_log (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,guid INT UNSIGNED NOT NULL, id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,guid INT UNSIGNED NOT NULL,
name VARCHAR(64) NOT NULL,at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, name VARCHAR(64) NOT NULL,at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
detail VARCHAR(255) NOT NULL,INDEX(guid,at))]]) detail VARCHAR(255) NOT NULL,INDEX(guid,at))]])
AuthDBExecute([[CREATE TABLE IF NOT EXISTS azerothai_recovery_results (
attempt_id BIGINT UNSIGNED PRIMARY KEY,status VARCHAR(24) NOT NULL,
checked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,detail VARCHAR(255) NOT NULL)]])
local function checkResults(now)
for guid,r in pairs(pendingResults) do
local p=GetPlayerByName(r.name);local status,detail
if not p or not p:IsPlayerbot() or p:GetGUIDLow()~=guid or p:GetAccountId()~=r.account or p:IsDead() then
status='unverified';detail='Resident unavailable before verification'
elseif p:GetMapId()~=r.map or p:GetLevel()~=r.level or (p:GetX()-r.x)^2+(p:GetY()-r.y)^2+(p:GetZ()-r.z)^2>25 then
status='resumed';detail='Native movement or level progress observed after recovery'
elseif now-r.at>=180 then
status='no_progress';detail='No movement or level progress observed within three minutes'
end
if status then
AuthDBExecute('INSERT IGNORE INTO azerothai_recovery_results(attempt_id,status,detail) SELECT id,'..sqltext(status)..','..sqltext(detail)..' FROM azerothai_recovery_log WHERE guid='..guid..' AND UNIX_TIMESTAMP(at)='..r.at..' ORDER BY id DESC LIMIT 1')
pendingResults[guid]=nil
end
end
end
function AzerothAISetRecoveryIntents(rows) function AzerothAISetRecoveryIntents(rows)
local nextIntents={} local nextIntents={}
for _,r in ipairs(rows) do for _,r in ipairs(rows) do
@@ -31,6 +50,7 @@ local function safe(p,a)
end end
local function sample() local function sample()
local now=os.time() local now=os.time()
checkResults(now)
for guid,r in pairs(intents) do for guid,r in pairs(intents) do
local p=GetPlayerByName(r.name) local p=GetPlayerByName(r.name)
if now-r.at>120 or not p or not p:IsPlayerbot() or p:GetGUIDLow()~=guid or p:GetAccountId()~=r.account then if now-r.at>120 or not p or not p:IsPlayerbot() or p:GetGUIDLow()~=guid or p:GetAccountId()~=r.account then
@@ -55,6 +75,7 @@ local function sample()
local moved=a:DoSpecificAction('move random',true) local moved=a:DoSpecificAction('move random',true)
local detail=(staleFlight and 'Cleared stale flight; ' or '')..'AI replanned; walking request='..tostring(moved) local detail=(staleFlight and 'Cleared stale flight; ' or '')..'AI replanned; walking request='..tostring(moved)
AuthDBExecute('INSERT INTO azerothai_recovery_log(guid,name,detail) VALUES ('..guid..','..sqltext(r.name)..','..sqltext(detail)..')') AuthDBExecute('INSERT INTO azerothai_recovery_log(guid,name,detail) VALUES ('..guid..','..sqltext(r.name)..','..sqltext(detail)..')')
pendingResults[guid]={name=r.name,account=r.account,at=now,x=x,y=y,z=z,map=map,level=level}
PrintInfo('[AzerothAI recovery] '..r.name..': '..detail) PrintInfo('[AzerothAI recovery] '..r.name..': '..detail)
end end
old.since=now old.since=now
+27 -6
View File
@@ -1,10 +1,17 @@
import {outcomeChanges,attentionItems,counterKeys,type OutcomeBaseline,type Perf,type Recovery,type Health} from '@/core/realm-outcomes';
import {env} from 'cloudflare:workers'; import {env} from 'cloudflare:workers';
import {z} from 'zod'; import {z} from 'zod';
import {D1Store} from '@/core/store'; import {D1Store} from '@/core/store';
import type {RuntimeConfig} from '@/core/types'; import type {RuntimeConfig} from '@/core/types';
import {observeInsights,activityReason,reportWindow,summarize,DAY,type Cursor,type Evidence} from '@/core/realm-insights'; 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)}); 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),status:z.enum(['pending','resumed','no_progress','unverified']).optional(),checkedAt:z.number().nonnegative().optional(),result:z.string().max(255).optional()});
const count=z.number().int().nonnegative();
const outcome=z.object({guid:count,name:z.string().max(64),quests:count,abandoned:count,lootEvents:count,lootItems:count,partyKills:count,rewarded:count});
const measurement=z.number().finite().nonnegative();
const performance=z.object({at:measurement,enabled:z.boolean(),ticks:measurement,averageTickMs:measurement,rows:z.array(z.object({category:z.string().max(40),name:z.string().max(160),calls:measurement,totalMs:measurement,averageMs:measurement,maxMs:measurement,perTickMs:measurement})).max(20)});
async function ensure(){await env.DB!.batch([ async function ensure(){await env.DB!.batch([
env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_native (id INTEGER PRIMARY KEY,payload TEXT)'),
env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_performance (at INTEGER PRIMARY KEY,payload TEXT)'),
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_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 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 INDEX IF NOT EXISTS insights_evidence_at ON insights_evidence(at)'),
@@ -23,29 +30,43 @@ export async function GET(){
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 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 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)); 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'}}); const nativeRow=await env.DB!.prepare('SELECT payload FROM insights_native WHERE id=1').first<{payload:string}>();const native:OutcomeBaseline|undefined=nativeRow?JSON.parse(nativeRow.payload):undefined;
const perfRows=await env.DB!.prepare('SELECT payload FROM insights_performance ORDER BY at DESC LIMIT 144').all<{payload:string}>();const perfHistory:Perf[]=perfRows.results.map(r=>JSON.parse(r.payload)),perf=perfHistory[0]??null;
let health:Health[]=[];if(await env.DB!.prepare("SELECT name FROM sqlite_master WHERE name='operations_samples'").first()){const h=await env.DB!.prepare('SELECT payload FROM operations_samples').all<{payload:string}>();health=h.results.map(r=>JSON.parse(r.payload));}
const results=await env.DB!.prepare('SELECT payload FROM insights_evidence WHERE at>=? ORDER BY at DESC LIMIT 60').bind(now-DAY).all<{payload:string}>();const recent:Evidence[]=results.results.map(r=>JSON.parse(r.payload));
const aggregates=await env.DB!.prepare("SELECT json_extract(payload,'$.kind') AS kind,json_extract(payload,'$.name') AS name,SUM(json_extract(payload,'$.amount')) AS amount FROM insights_evidence WHERE at>=? GROUP BY kind,name").bind(now-DAY).all<{kind:string;name:string;amount:number}>();
const totals:Record<string,number>={};for(const e of aggregates.results)totals[e.kind]=(totals[e.kind]??0)+e.amount;
const byBot=s.bots.filter(b=>b.autonomy?.managed&&b.status==='online').map(b=>({name:b.name,zone:b.zone,grouped:!!b.world?.groupId&&now-b.world.at<120,groupBots:(b.world?.groupNames??[]).filter(n=>s.bots.some(o=>o.name===n&&o.autonomy?.managed)),totals:Object.fromEntries(counterKeys.map(k=>[k,aggregates.results.filter(e=>e.name===b.name&&e.kind===k).reduce((sum,e)=>sum+e.amount,0)]))}));
const publicRecoveries=attempts.map((r:Recovery)=>({name:r.name,at:r.at,detail:r.detail,status:r.status??'unverified',checkedAt:r.checkedAt??null,result:r.result??'No verification recorded'}));
const attention=attentionItems(s,now,cursor?.at??null,native?.at??null,perf,attempts,health);
return Response.json({attention,outcomes:{at:native?.at??null,startedAt:native?.startedAt??null,windowStart:Math.max(now-DAY,native?.startedAt??now),totals,bots:byBot,recent:recent.slice(0,60)},recoveryResults:publicRecoveries,performance:{latest:perf,history:perfHistory.map(p=>({at:p.at,averageTickMs:p.averageTickMs,ticks:p.ticks})).reverse()},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){ 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}); 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{ 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 raw=await request.text();if(raw.length>200000)throw new Error('Payload too large');const body=z.object({at:z.number().finite(),recoveries:z.array(recovery).max(100),outcomes:z.array(outcome).max(2000).nullable().optional(),performance:performance.nullable().optional()}).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 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 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 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 state=await new D1Store().load();const {cursor,events}=observeInsights(state,old,now);
let native:OutcomeBaseline|undefined;
if(body.outcomes){const before=await env.DB!.prepare('SELECT payload FROM insights_native WHERE id=1').first<{payload:string}>();const safe=body.outcomes.filter(r=>state.bots.some(b=>b.autonomy?.managed&&b.autonomy?.characterGuid===r.guid&&b.name===r.name));const changed=outcomeChanges(safe,before?JSON.parse(before.payload):undefined,now);native=changed.baseline;events.push(...changed.events);}
const statements=events.map(e=>env.DB!.prepare('INSERT OR IGNORE INTO insights_evidence VALUES (?,?,?)').bind(e.id,e.at,JSON.stringify(e))); 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){ for(const r of body.recoveries){
if(r.at>now+60)continue; if(r.at>now+60)continue;
statements.push(env.DB!.prepare('INSERT OR IGNORE INTO insights_recoveries VALUES (?,?)').bind(r.id,JSON.stringify(r))); statements.push(env.DB!.prepare('INSERT INTO insights_recoveries VALUES (?,?) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload').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)));} 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)));}
} }
if(native)statements.push(env.DB!.prepare('INSERT INTO insights_native VALUES (1,?) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload').bind(JSON.stringify(native)));
if(body.performance&&body.performance.at<=now+60&&now-body.performance.at<720)statements.push(env.DB!.prepare('INSERT OR REPLACE INTO insights_performance VALUES (?,?)').bind(body.performance.at,JSON.stringify(body.performance)));
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)); 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); await env.DB!.batch(statements);
for(let start=Math.max(reportWindow(cursor.startedAt),reportWindow(now)-30*DAY);start<reportWindow(now);start+=DAY){ 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; 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!.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)')]); await env.DB!.batch([env.DB!.prepare('DELETE FROM insights_performance WHERE at NOT IN (SELECT at FROM insights_performance ORDER BY at DESC LIMIT 144)'),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}); return Response.json({ok:true});
}catch(e){return Response.json({error:e instanceof Error?e.message:'Insight collection failed'},{status:400});} }catch(e){return Response.json({error:e instanceof Error?e.message:'Insight collection failed'},{status:400});}
} }
+1
View File
File diff suppressed because one or more lines are too long
+7 -4
View File
@@ -1,8 +1,9 @@
'use client'; 'use client';
import {RealmDiagnostics} from './realm-diagnostics';
import {RealmInsights} from './realm-insights'; 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 { CheckCircle2,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';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -18,7 +19,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],['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 NAV=[['Dashboard',LayoutDashboard],['World Monitor',Globe2],['Needs attention',Shield],['Gameplay Results',CheckCircle2],['Recovery Results',RefreshCw],['Bot Performance',Activity],['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}));
@@ -26,6 +27,7 @@ function Avatar({bot}:{bot:Bot}){return <div className={`avatar ${bot.role}`} ar
function Panel({title,icon:Icon,children,action,className=''}:any){return <section className={`panel ${className}`}><div className="panel-head"><h2>{Icon&&<Icon/>}{title}</h2>{action}</div>{children}</section>} function Panel({title,icon:Icon,children,action,className=''}:any){return <section className={`panel ${className}`}><div className="panel-head"><h2>{Icon&&<Icon/>}{title}</h2>{action}</div>{children}</section>}
function Events({events}:{events:RealmEvent[]}){return <div>{events.length?[...events].reverse().map(e=><div className="event-row" key={e.id}><span className="event-time">{new Date(e.at).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span><span className={`event-kind ${e.kind}`}>{e.kind}</span><span>{e.message}</span></div>):<div className="empty-note">No events match this filter.</div>}</div>} function Events({events}:{events:RealmEvent[]}){return <div>{events.length?[...events].reverse().map(e=><div className="event-row" key={e.id}><span className="event-time">{new Date(e.at).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span><span className={`event-kind ${e.kind}`}>{e.kind}</span><span>{e.message}</span></div>):<div className="empty-note">No events match this filter.</div>}</div>}
export default function Home({readOnly=false}:{readOnly?:boolean}){ export default function Home({readOnly=false}:{readOnly?:boolean}){
const [focusName,setFocusName]=useState('');
const [state,setState]=useState<State|null>(null),[page,setPage]=useState('Dashboard'),[error,setError]=useState(''),[busy,setBusy]=useState(false),[query,setQuery]=useState(''),[filter,setFilter]=useState('all'),[botId,setBotId]=useState<string|null>(null),[createOpen,setCreateOpen]=useState(false),[recipe,setRecipe]=useState<Recipe|null>(null),[recipeOpen,setRecipeOpen]=useState(false),[confirm,setConfirm]=useState<{title:string;description:string;action:any}|null>(null),[capabilities,setCapabilities]=useState<any>({}),[monitor,setMonitor]=useState<any>(null); const [state,setState]=useState<State|null>(null),[page,setPage]=useState('Dashboard'),[error,setError]=useState(''),[busy,setBusy]=useState(false),[query,setQuery]=useState(''),[filter,setFilter]=useState('all'),[botId,setBotId]=useState<string|null>(null),[createOpen,setCreateOpen]=useState(false),[recipe,setRecipe]=useState<Recipe|null>(null),[recipeOpen,setRecipeOpen]=useState(false),[confirm,setConfirm]=useState<{title:string;description:string;action:any}|null>(null),[capabilities,setCapabilities]=useState<any>({}),[monitor,setMonitor]=useState<any>(null);
const refresh=useCallback(async()=>{try{const r=await fetch('/api/state');const d:any=await r.json();if(!r.ok)throw new Error(d.error);setState(d);setError('');}catch(e){setError(e instanceof Error?e.message:'Could not load realm');}},[]); const refresh=useCallback(async()=>{try{const r=await fetch('/api/state');const d:any=await r.json();if(!r.ok)throw new Error(d.error);setState(d);setError('');}catch(e){setError(e instanceof Error?e.message:'Could not load realm');}},[]);
useEffect(()=>{void refresh();void fetch('/api/health').then(r=>r.json()).then(setCapabilities).catch(()=>{});const timer=setInterval(refresh,5000);return()=>clearInterval(timer);},[refresh]); useEffect(()=>{void refresh();void fetch('/api/health').then(r=>r.json()).then(setCapabilities).catch(()=>{});const timer=setInterval(refresh,5000);return()=>clearInterval(timer);},[refresh]);
@@ -35,7 +37,7 @@ export default function Home({readOnly=false}:{readOnly?:boolean}){
useEffect(()=>{if(readOnly)return;const context=(document as any).modelContext;if(!context?.registerTool)return;const controller=new AbortController();const register=(tool:any)=>{try{Promise.resolve(context.registerTool(tool,{signal:controller.signal})).catch(()=>{});}catch{}}; useEffect(()=>{if(readOnly)return;const context=(document as any).modelContext;if(!context?.registerTool)return;const controller=new AbortController();const register=(tool:any)=>{try{Promise.resolve(context.registerTool(tool,{signal:controller.signal})).catch(()=>{});}catch{}};
register({name:'azerothai_read_realm',description:'Read current bot identities, recipes, director settings and recent events.',inputSchema:{type:'object',properties:{},additionalProperties:false},annotations:{readOnlyHint:true},execute:async()=>{const r=await fetch('/api/state');if(!r.ok)throw new Error('Realm unavailable');return r.json();}}); register({name:'azerothai_read_realm',description:'Read current bot identities, recipes, director settings and recent events.',inputSchema:{type:'object',properties:{},additionalProperties:false},annotations:{readOnlyHint:true},execute:async()=>{const r=await fetch('/api/state');if(!r.ok)throw new Error('Realm unavailable');return r.json();}});
register({name:'azerothai_create_bot',description:'Create an offline bot identity; does not spawn it in game.',inputSchema:{type:'object',properties:{name:{type:'string'},role:{type:'string'},race:{type:'string'},recipeId:{type:'string'},goal:{type:'string'}},required:['name','role','race','recipeId','goal'],additionalProperties:false},annotations:{readOnlyHint:false},execute:async(input:any)=>act({...input,type:'createBot'})});return()=>controller.abort();},[act,readOnly]); register({name:'azerothai_create_bot',description:'Create an offline bot identity; does not spawn it in game.',inputSchema:{type:'object',properties:{name:{type:'string'},role:{type:'string'},race:{type:'string'},recipeId:{type:'string'},goal:{type:'string'}},required:['name','role','race','recipeId','goal'],additionalProperties:false},annotations:{readOnlyHint:false},execute:async(input:any)=>act({...input,type:'createBot'})});return()=>controller.abort();},[act,readOnly]);
function nav(p:string){setPage(p);setQuery('');setFilter('all');} function nav(p:string,name=''){setPage(p);setQuery('');setFilter('all');setFocusName(name);}
const selected=state?.bots.find(b=>b.id===botId); const selected=state?.bots.find(b=>b.id===botId);
const visible=state?.bots.filter(b=>(`${b.name} ${b.role} ${b.zone}`.toLowerCase().includes(query.toLowerCase()))&&(filter==='all'||b.status===filter))??[]; const visible=state?.bots.filter(b=>(`${b.name} ${b.role} ${b.zone}`.toLowerCase().includes(query.toLowerCase()))&&(filter==='all'||b.status===filter))??[];
function botTable(bots:Bot[]){return <Table className="data-table"><TableHeader><TableRow><TableHead>Character</TableHead><TableHead>Status</TableHead><TableHead>Location</TableHead><TableHead>Recipe</TableHead><TableHead/></TableRow></TableHeader><TableBody>{bots.map(b=><TableRow key={b.id} onClick={()=>setBotId(b.id)}><TableCell><div className="bot-name"><Avatar bot={b}/><div><button onClick={()=>setBotId(b.id)}><strong>{b.name}</strong></button><small>Lv. {b.level} · {b.race} {b.role}</small></div></div></TableCell><TableCell><span className={`tag ${b.status}`}>{b.status}</span></TableCell><TableCell>{b.zone}</TableCell><TableCell><small>{b.personality.name.replace('The ','')}</small></TableCell><TableCell><ArrowRight size={15}/></TableCell></TableRow>)}</TableBody></Table>} function botTable(bots:Bot[]){return <Table className="data-table"><TableHeader><TableRow><TableHead>Character</TableHead><TableHead>Status</TableHead><TableHead>Location</TableHead><TableHead>Recipe</TableHead><TableHead/></TableRow></TableHeader><TableBody>{bots.map(b=><TableRow key={b.id} onClick={()=>setBotId(b.id)}><TableCell><div className="bot-name"><Avatar bot={b}/><div><button onClick={()=>setBotId(b.id)}><strong>{b.name}</strong></button><small>Lv. {b.level} · {b.race} {b.role}</small></div></div></TableCell><TableCell><span className={`tag ${b.status}`}>{b.status}</span></TableCell><TableCell>{b.zone}</TableCell><TableCell><small>{b.personality.name.replace('The ','')}</small></TableCell><TableCell><ArrowRight size={15}/></TableCell></TableRow>)}</TableBody></Table>}
@@ -43,7 +45,8 @@ 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}/>} {['Needs attention','Gameplay Results','Recovery Results','Bot Performance'].includes(page)&&<RealmDiagnostics key={page+focusName} page={page} initialQuery={focusName} onNavigate={nav}/>}
{(page==='Nightly Report'||page==='Bot Activity')&&<RealmInsights key={page+focusName} mode={page} initialQuery={focusName}/>}
{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></>}
+28
View File
@@ -0,0 +1,28 @@
'use client';
import {useEffect,useState} from 'react';
import {Activity,CheckCircle2,AlertTriangle,ArrowRight,Clock3,Search,ShieldCheck,Package,ScrollText,Users,Timer} from 'lucide-react';
import type {Attention,Perf} from '@/core/realm-outcomes';
import type {Evidence} from '@/core/realm-insights';
type Recovery={name:string;at:number;status:string;result:string;detail:string;checkedAt:number|null};
type BotResult={name:string;zone:string;grouped:boolean;groupBots:string[];totals:Record<string,number>};
type Data={collectedAt:number|null;attention:Attention[];outcomes:{at:number|null;startedAt:number|null;windowStart:number;totals:Record<string,number>;bots:BotResult[];recent:Evidence[]};recoveryResults:Recovery[];performance:{latest:Perf|null;history:{at:number;averageTickMs:number;ticks:number}[]}};
const stamp=(at:number|null)=>at?new Date(at*1000).toLocaleString():'Awaiting observations';
const labels:Record<string,string>={resumed:'Progress resumed',no_progress:'No progress observed',pending:'Verification pending',unverified:'Could not verify'};
function Metric({label,value,note,icon:Icon=Activity,tone=''}:{label:string;value:string|number;note:string;icon?:typeof Activity;tone?:string}){return <div className={'diag-metric '+tone}><div className="diag-metric-label"><Icon size={18}/>{label}</div><strong>{value}</strong><small>{note}</small></div>}
export function RealmDiagnostics({page,onNavigate,initialQuery=''}:{page:string;initialQuery?:string;onNavigate:(page:string,name?:string)=>void}){
const [data,setData]=useState<Data|null>(null),[error,setError]=useState(''),[query,setQuery]=useState(initialQuery),[filter,setFilter]=useState('all');
useEffect(()=>{setQuery(initialQuery);setFilter('all');},[page,initialQuery]);
useEffect(()=>{let stop=false;const poll=async()=>{try{const r=await fetch('/api/realm-insights');if(!r.ok)throw new Error('Realm diagnostics unavailable');const d=await r.json() as Data;if(!stop){setData(d);setError('');}}catch{if(!stop)setError('Connection interrupted. Previously loaded readings may be stale.');}};void poll();const timer=setInterval(poll,15000);return()=>{stop=true;clearInterval(timer);};},[]);
if(!data)return <div className="empty-note" role="status">{error||'Gathering realm evidence…'}</div>;
const critical=data.attention.filter(a=>a.severity==='critical').length,perf=data.performance.latest,o=data.outcomes;
const stale=!data.collectedAt||Date.now()/1000-data.collectedAt>180;
const metrics=o.totals,recoveries=data.recoveryResults;
const visibleBots=o.bots.filter(b=>(b.name+' '+b.zone).toLowerCase().includes(query.toLowerCase())&&(filter!=='grouped'||b.grouped));
const attempts=recoveries.filter(r=>r.name.toLowerCase().includes(query.toLowerCase())&&(filter==='all'||r.status===filter));
return <div className="diagnostics"><div className="diag-hero"><div><div className="eyebrow">EVIDENCE FROM YOUR REALM</div><h2>{page==='Needs attention'?'A clear view of what needs you.':page==='Gameplay Results'?'Plans become progress.':page==='Recovery Results'?'Did the recovery help?':'Inside the bot engine.'}</h2><p>{page==='Needs attention'?'Prioritized signals, with a direct path to the details.':page==='Gameplay Results'?'Native quest, loot, and group observations from the last 24 hours.':page==='Recovery Results'?'Native follow-up separates an attempted fix from observed progress.':'Measured execution time, sampled every five minutes.'}</p></div><span className={'diag-status '+(stale?'warning':'good')}><span/>{stale?'Collection delayed':'Collector active'}</span></div><p className="diag-updated"><Clock3 size={14}/> Last collection {stamp(data.collectedAt)} · View refreshes every 15 seconds</p>{error&&<div className="notice" role="alert">{error}</div>}
{page==='Needs attention'&&<><div className="diag-metrics"><Metric label="Needs attention" value={data.attention.length} note="Current signals, not historical totals" icon={AlertTriangle} tone={data.attention.length?'warning':'good'}/><Metric label="Critical" value={critical} note="Resource pressure or population over cap" icon={ShieldCheck} tone={critical?'critical':''}/><Metric label="Online residents" value={o.bots.length} note="Target: 50 · 25 per faction" icon={Users}/><Metric label="Observed quest rewards" value={metrics.rewarded??0} note="New rewarded quest records · 24 hours" icon={ScrollText}/></div><div className="diag-section-title"><h2>Follow-up queue</h2><span className="fineprint">Critical signals first</span></div>{data.attention.length?<div className="diag-alerts">{data.attention.map(a=><button className={'diag-alert '+a.severity} key={a.key} onClick={()=>onNavigate(a.target,a.name)}><AlertTriangle size={20}/><div><span className="diag-label">{a.severity}</span><h3>{a.title}</h3><p>{a.detail}</p><small>Open {a.target}</small></div><ArrowRight size={18}/></button>)}</div>:<div className="diag-clear"><CheckCircle2 size={36}/><h3>No current attention signals</h3><p>The monitored checks are clear. This is not proof that every gameplay path is bug-free.</p></div>}<div className="diag-links">{['Gameplay Results','Recovery Results','Bot Performance'].map(p=><button key={p} onClick={()=>onNavigate(p)}>{p}<ArrowRight size={16}/></button>)}</div></>}
{page==='Gameplay Results'&&<><div className="diag-metrics"><Metric label="Quest rewards" value={metrics.rewarded??0} note="New rewarded quest records" icon={ScrollText}/><Metric label="Items looted" value={metrics.lootItems??0} note={`${metrics.lootEvents??0} native loot collections`} icon={Package}/><Metric label="Shared-target kills" value={metrics.partyKills??0} note="Killer had a nearby bot fighting the same target" icon={Users}/><Metric label="Quests abandoned" value={metrics.abandoned??0} note="An abandonment is not automatically a bug" icon={Activity}/></div><div className="diag-note">Evidence since {stamp(o.startedAt)}. Native counters last collected {stamp(o.at)}. Quest rewards count newly recorded quest IDs; repeated rewards for an already recorded quest may not increase this count. Quest objectives completed: <strong>{metrics.quests??0}</strong>. Party actions rejected: <strong>{metrics.partyFailed??0}</strong>. Gaps or a server reload can miss unflushed events.</div><div className="diag-toolbar"><label className="diag-search"><Search size={17}/><input aria-label="Search gameplay results" placeholder="Find a resident or zone…" value={query} onChange={e=>setQuery(e.target.value)}/></label><select aria-label="Filter gameplay results" value={filter} onChange={e=>setFilter(e.target.value)}><option value="all">All online residents</option><option value="grouped">Currently grouped</option></select></div><div className="diag-bot-grid">{visibleBots.map(b=><section className="diag-bot" key={b.name}><div className="diag-bot-head"><span className="diag-avatar">{b.name[0]}</span><div><h3>{b.name}</h3><small>{b.zone}</small></div><span className={'diag-chip '+(b.grouped?'good':'')}>{b.grouped?'Grouped':'Solo / unknown'}</span></div><div className="diag-bot-values"><div><strong>{b.totals.rewarded}</strong><small>Quest rewards</small></div><div><strong>{b.totals.lootItems}</strong><small>Items looted</small></div><div><strong>{b.totals.partyKills}</strong><small>Shared kills</small></div></div><details><summary>Evidence details</summary><p>Objectives completed: {b.totals.quests} · Abandoned: {b.totals.abandoned} · Loot collections: {b.totals.lootEvents}</p><p>Observed bot companions: {b.groupBots.filter(n=>n!==b.name).join(', ')||'None reported'}</p><button className="diag-text-link" onClick={()=>onNavigate('Bot Activity',b.name)}>View activity <ArrowRight size={14}/></button></details></section>)}</div>{!visibleBots.length&&<div className="empty-note">No residents match your filter.</div>}<details className="diag-timeline"><summary>Recent evidence ({o.recent.length})</summary>{o.recent.map(e=><div key={e.id}><small>{stamp(e.at)}</small><strong>{e.name}</strong><p>{e.detail} · +{e.amount}</p></div>)}</details></>}
{page==='Recovery Results'&&<><div className="diag-metrics">{['resumed','no_progress','pending','unverified'].map(status=><Metric key={status} label={labels[status]} value={recoveries.filter(r=>r.status===status).length} note="Among the latest 100 retained attempts" tone={status==='resumed'?'good':status==='no_progress'?'warning':''} icon={status==='resumed'?CheckCircle2:Clock3}/>)}</div><p className="diag-note">Progress resumed means native movement over five yards, a map change, or a level change was observed within the follow-up window. It does not prove lasting recovery or that the intervention caused it. Three minutes without that evidence is flagged for review. Restarts or unavailable residents may prevent verification.</p><div className="diag-toolbar"><label className="diag-search"><Search size={17}/><input aria-label="Search recovery results" placeholder="Find a resident…" value={query} onChange={e=>setQuery(e.target.value)}/></label><select aria-label="Filter recovery results" value={filter} onChange={e=>setFilter(e.target.value)}><option value="all">All outcomes</option>{Object.entries(labels).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></div><div className="diag-alerts">{attempts.map((r,i)=><article className="diag-recovery" key={r.at+':'+r.name+':'+i}><div><h3>{r.name}</h3><small>{stamp(r.at)}</small></div><span className={'diag-chip '+(r.status==='resumed'?'good':r.status==='no_progress'?'warning':'')}>{labels[r.status]}</span><p>{r.result}</p><details><summary>Attempt details</summary><p>{r.detail}</p><small>Checked: {stamp(r.checkedAt)}</small></details></article>)}</div>{!attempts.length&&<div className="diag-clear"><ShieldCheck size={36}/><h3>No matching recovery attempts</h3><p>Results appear after the native guard attempts a recovery. No intervention is forced to populate this view.</p></div>}</>}
{page==='Bot Performance'&&<><div className="diag-metrics"><Metric label="Average full tick" value={perf?perf.averageTickMs.toFixed(2)+' ms':'—'} note="Cumulative mean since profiler start/reset" icon={Timer}/><Metric label="Measured full ticks" value={perf?Math.round(perf.ticks).toLocaleString():'—'} note="Bot update cycles, not world-server ticks"/><Metric label="Profiler" value={perf?.enabled?'Enabled':'Unavailable'} note={stamp(perf?.at??null)} tone={perf?.enabled?'good':'warning'}/><Metric label="Stored samples" value={data.performance.history.length} note="Up to 144 snapshots retained" icon={Clock3}/></div>{(!perf||Date.now()/1000-perf.at>720)&&<div className="notice">Profiler data is missing or stale. Measurements below are not current.</div>}<section className="panel"><div className="panel-head"><h2>Average full-tick history</h2><small>Milliseconds · cumulative average</small></div><div className="panel-body"><div className="diag-bars" role="img" aria-label="History of cumulative average bot tick duration">{data.performance.history.slice(-48).map(p=><div key={p.at} title={`${stamp(p.at)}: ${p.averageTickMs.toFixed(3)} ms`} style={{height:Math.max(3,100*p.averageTickMs/Math.max(.001,...data.performance.history.slice(-48).map(x=>x.averageTickMs)))+'%'}}/>)}</div><p className="fineprint">{stamp(data.performance.history[0]?.at??null)} — {stamp(perf?.at??null)}. Timers can overlap; they must not be added together or interpreted as CPU utilization.</p></div></section><section className="panel" style={{marginTop:20}}><div className="panel-head"><h2>Most expensive measured operations</h2><small>Sorted by accumulated time</small></div><div className="diag-table-scroll"><table className="diag-table"><thead><tr><th>Operation</th><th>Calls</th><th>Mean</th><th>Maximum</th><th>Per full tick</th></tr></thead><tbody>{perf?.rows.map((r,i)=><tr key={i}><td><strong>{r.name}</strong><small>{r.category}</small></td><td>{Math.round(r.calls).toLocaleString()}</td><td>{r.averageMs.toFixed(3)} ms</td><td>{r.maxMs.toFixed(3)} ms</td><td>{r.perTickMs.toFixed(3)} ms</td></tr>)}</tbody></table></div>{!perf?.rows.length&&<p className="empty-note">Waiting for the first native profiler export.</p>}</section></>}
</div>;
}
+2 -2
View File
@@ -7,8 +7,8 @@ type Activity={name:string;zone:string;action:string;label:string;detail:string;
type Insights={collectedAt:number|null;startedAt:number|null;nextReportAt:number;current:Report;reports:Report[];activity:Activity[]}; 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 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'}; 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}){ export function RealmInsights({mode,initialQuery=''}:{mode:string;initialQuery?:string}){
const [data,setData]=useState<Insights|null>(null),[error,setError]=useState(''),[selected,setSelected]=useState('current'),[query,setQuery]=useState(''),[attention,setAttention]=useState(false); const [data,setData]=useState<Insights|null>(null),[error,setError]=useState(''),[selected,setSelected]=useState('current'),[query,setQuery]=useState(initialQuery),[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);};},[]); 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>; 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 report=selected==='current'?data.current:data.reports.find(r=>String(r.start)===selected)??data.current;
+2 -1
View File
@@ -23,7 +23,8 @@ export function observeInsights(s:State,previous:Cursor|undefined,now:number){
} }
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 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??[]){ for(const e of s.community?.feed??[]){
if(e.status==='confirmed')cursor.social.push(e.id); if(e.status==='confirmed'||e.status==='failed')cursor.social.push(e.id);
if(previous&&Date.parse(e.at)/1000>=previous.at&&!previous.social.includes(e.id)&&e.status==='failed'&&e.kind==='party'&&!e.id.endsWith(':proposal'))add('social:'+e.id,'partyFailed',e.names.join(' & '),1,'Game rejected party action');
if(!previous||previous.social.includes(e.id)||e.status!=='confirmed'||e.id.endsWith(':proposal'))continue; 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; 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==='conversation'&&e.id.startsWith('social-')&&e.id.endsWith(':reply'))add('social:'+e.id,'conversations',e.names.join(' & '),1,'Completed bot-to-bot exchange',at);
+37
View File
@@ -0,0 +1,37 @@
import type {State} from './types';
import {activityReason,type Evidence} from './realm-insights';
export const counterKeys=['quests','abandoned','lootEvents','lootItems','partyKills','rewarded'] as const;
export type NativeOutcome={guid:number;name:string}&Record<typeof counterKeys[number],number>;
export type OutcomeBaseline={at:number;startedAt:number;rows:NativeOutcome[]};
export function outcomeChanges(rows:NativeOutcome[],previous:OutcomeBaseline|undefined,now:number){
const events:Evidence[]=[];
for(const r of rows){const old=previous?.rows.find(b=>b.guid===r.guid);if(!old)continue;
for(const key of counterKeys){const amount=r[key]-old[key];if(amount>0)events.push({id:`outcome:${r.guid}:${key}:${now}:${r[key]}`,at:now,kind:key,name:r.name,amount,detail:({quests:'Quest objectives completed',abandoned:'Quests abandoned',lootEvents:'Loot collections',lootItems:'Items collected',partyKills:'Kills with a nearby bot attacking the same target',rewarded:'New rewarded quest records'})[key]});}
}
return {baseline:{at:now,startedAt:previous?.startedAt??now,rows},events};
}
export type Recovery={id:number;guid:number;name:string;at:number;detail:string;status?:'pending'|'resumed'|'no_progress'|'unverified';checkedAt?:number;result?:string};
export type Perf={at:number;enabled:boolean;ticks:number;averageTickMs:number;rows:{category:string;name:string;calls:number;totalMs:number;averageMs:number;maxMs:number;perTickMs:number}[]};
export type Health={host:string;at:number;memoryAvailableMB:number;memoryTotalMB:number;diskFreeGB:number;cpuPercent:number;modelAvailable?:boolean;runtimeService?:string};
export type Attention={key:string;severity:'warning'|'critical';title:string;detail:string;target:string;name?:string};
export function attentionItems(s:State,now:number,collectedAt:number|null,outcomeAt:number|null,perf:Perf|null,recoveries:Recovery[],health:Health[]){
const items:Attention[]=[];
const add=(key:string,severity:Attention['severity'],title:string,detail:string,target:string,name?:string)=>items.push({key,severity,title,detail,target,name});
if(!collectedAt||now-collectedAt>180)add('collector','warning','Observations are delayed','The minute collector has not delivered a fresh sample.','System Health');
if(!outcomeAt||now-outcomeAt>180)add('outcomes','warning','Gameplay evidence is delayed','Quest and loot counters are unavailable or stale.','Gameplay Results');
if(!perf||!perf.enabled||now-perf.at>720)add('profiler','warning','Bot profiler needs attention','A current enabled profiler sample is not available.','Bot Performance');
for(const host of ['ubuntu','windows']){const h=health.find(v=>v.host===host);if(!h||now-h.at>180){add('host:'+host,'warning',`${host} telemetry delayed`,'Host resource readings are missing or stale.','System Health');continue;}
if(h.memoryAvailableMB<Math.max(256,h.memoryTotalMB*.05))add('memory:'+host,'critical',`${host} memory pressure`,`${Math.round(h.memoryAvailableMB)} MB available. Check host workload before raising bot population.`,'System Health');
if(h.diskFreeGB<4)add('disk:'+host,'critical',`${host} disk space low`,`${h.diskFreeGB.toFixed(1)} GB free.`,'System Health');
if(h.cpuPercent>90)add('cpu:'+host,'warning',`${host} CPU is busy`,'Latest sample exceeds 90%; this alone does not establish sustained overload.','System Health');
if(host==='ubuntu'&&h.modelAvailable===false)add('model','warning','Language model unavailable','Bot conversations may be delayed.','System Health');
}
const online=s.bots.filter(b=>b.autonomy?.managed&&b.status==='online');
if(now-(s.aleRosterAt??0)<120){if(online.length!==50)add('population',online.length>50?'critical':'warning',`${online.length} / 50 bots online`,online.length>50?'The hard cap needs investigation.':'A vacancy may be waiting for its replacement.','Bots');
const alliance=online.filter(b=>b.world?.team===0).length,horde=online.filter(b=>b.world?.team===1).length;
if(alliance!==25||horde!==25)add('factions','warning','Faction balance differs from target',`${alliance} Alliance · ${horde} Horde · target 25 each.`,'World Monitor');}
for(const b of online){const reason=activityReason(b,now);if(['Possible stall','Telemetry delayed'].includes(reason.label))add('bot:'+b.id,'warning',b.name+' · '+reason.label,reason.detail,'Bot Activity',b.name);}
const latest=new Map<number,Recovery>();for(const r of [...recoveries].sort((a,b)=>b.at-a.at))if(!latest.has(r.guid))latest.set(r.guid,r);
for(const r of latest.values())if(r.status==='no_progress'&&now-r.at<3600&&online.some(b=>b.autonomy?.characterGuid===r.guid&&activityReason(b,now).label==='Possible stall'))add('recovery:'+r.id,'warning',r.name+' · recovery needs follow-up','No native movement or level progress was observed within three minutes.','Recovery Results',r.name);
return items.sort((a,b)=>Number(b.severity==='critical')-Number(a.severity==='critical'));
}
+12
View File
@@ -9,3 +9,15 @@ Reports count observed level gains, verified deaths, first online appearances, c
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. 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. `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.
## Gameplay, recovery and performance diagnostics
Needs attention links resource pressure, stale collectors, population/faction mismatches, possible stalls and unresolved recovery follow-ups to their detail views. These checks are evidence-based signals, not an assurance that all game mechanics work.
Gameplay Results uses native ALE quest-completion (54), quest-abandon (38), item-loot (32) and shared-target kill (7) observations. Counters are flushed to `acore_auth.azerothai_outcomes` every minute. An unexpected reload/crash can lose that minute's unflushed observations. Rewarded quest IDs are counted from `character_queststatus_rewarded`; repeat rewards for an existing record are not counted again. The collector establishes a baseline before reporting deltas, so old character history is excluded. A quest abandonment is not classified as a failure. Confirmed party commands and rejected party actions are distinct from observed current membership and shared-target combat.
Recovery Results retains up to 100 attempts. Native follow-up checks position, map and level after each intervention for three minutes. Movement beyond five yards, map change or level change means progress resumed; it does not establish causation or sustained success. Offline/dead/mismatched identities are unverified. After a reload, attempts without persisted verification remain unverified. Results are persisted in `azerothai_recovery_results` keyed to the exact attempt timestamp and character GUID. Existing recovery exclusions, ten-minute detection and 30-minute cooldown remain unchanged.
Bot Performance reads the native `playerbots pmon tick` JSON export every five minutes. Enable `AiPlayerbot.PerfMonEnabled = 1` in the installed module configuration; on an already-running server, toggle the native profiler once only if it is currently disabled. Deploy `ale/azerothai_outcomes.lua` alongside the existing bridge and recovery scripts. The collector reads only fixed container paths. Timings are converted from microseconds to milliseconds. Operation timings overlap; they are not CPU percentages. Cumulative means are explicitly labeled, and only the top 20 operations and 144 snapshots are retained in D1. No new chat/model calls are made by diagnostics.
Visitor access remains read-only. The same components render both monitors; diagnostics expose aggregate evidence and managed character names, excluding raw database identities, credentials, command sources and private chat. Native counter/result tables are included in the existing full auth database backup, and all D1 diagnostic tables are covered by the existing D1 backup.
+11 -4
View File
@@ -81,11 +81,18 @@ except Exception as e:maintenance.append('Recovery check needs attention: '+type
try: try:
recovery_rows=[] recovery_rows=[]
try: 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(): for line in sql("SELECT l.id,l.guid,l.name,UNIX_TIMESTAMP(l.at),l.detail,COALESCE(r.status,IF(l.at>NOW()-INTERVAL 3 MINUTE,'pending','unverified')),COALESCE(UNIX_TIMESTAMP(r.checked_at),0),COALESCE(r.detail,'No verification recorded') FROM acore_auth.azerothai_recovery_log l LEFT JOIN acore_auth.azerothai_recovery_results r ON r.attempt_id=l.id ORDER BY l.id DESC LIMIT 100").splitlines():
fields=line.split('\t',4) fields=line.split('\t')
recovery_rows.append(dict(id=int(fields[0]),guid=int(fields[1]),name=fields[2],at=int(fields[3]),detail=fields[4])) recovery_rows.append(dict(id=int(fields[0]),guid=int(fields[1]),name=fields[2],at=int(fields[3]),detail=fields[4],status=fields[5],checkedAt=int(fields[6]),result=fields[7]))
except Exception as e:maintenance.append('Recovery history unavailable: '+type(e).__name__) 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']}) spec=importlib.util.spec_from_file_location('realm_evidence',repo/'scripts/realm-evidence.py')
evidence=importlib.util.module_from_spec(spec);spec.loader.exec_module(evidence)
outcomes=None;perf=None
try:outcomes=evidence.collect(sql,current)
except Exception as e:maintenance.append('Gameplay counters unavailable: '+type(e).__name__)
try:perf=evidence.performance()
except Exception as e:maintenance.append('Profiler unavailable: '+type(e).__name__)
request=urllib.request.Request(base+'/api/realm-insights',data=json.dumps({'at':time.time(),'recoveries':recovery_rows,'outcomes':outcomes,'performance':perf}).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+cfg['AUTONOMY_TOKEN']})
with urllib.request.urlopen(request,timeout=20) as response:assert response.status==200 with urllib.request.urlopen(request,timeout=20) as response:assert response.status==200
maintenance.append('Realm report sampled') maintenance.append('Realm report sampled')
except Exception as e:maintenance.append('Realm report needs attention: '+type(e).__name__) except Exception as e:maintenance.append('Realm report needs attention: '+type(e).__name__)
+41
View File
@@ -0,0 +1,41 @@
"""Read native counters and profiler output; return bounded public measurements."""
import json,subprocess,time,math
COUNTERS=('quests','abandoned','lootEvents','lootItems','partyKills','rewarded')
def normalize_performance(raw):
def number(v):
try:
n=float(v);return n if math.isfinite(n) and n>=0 else 0
except (TypeError,ValueError):return 0
rows=[]
for metric in raw.get('metrics',[]):
for row in metric.get('rows',[]):
if number(row.get('count'))<=0:continue
rows.append({'category':str(metric.get('type',''))[:40],'name':str(row.get('name',''))[:160],'calls':number(row.get('count')),'totalMs':number(row.get('totalTime'))/1000,'averageMs':number(row.get('avgTime'))/1000,'maxMs':number(row.get('maxTime'))/1000,'perTickMs':number(row.get('timePerTick'))/1000})
# Nested timers overlap. Do not sum their percentages or call this CPU usage.
rows.sort(key=lambda r:r['totalMs'],reverse=True)
ticks=number(raw.get('fullTickCount'))
return {'at':number(raw.get('generatedAt')),'enabled':raw.get('enabled') is True,'ticks':ticks,'averageTickMs':number(raw.get('fullTickTotalTime'))/max(1,ticks)/1000,'rows':rows[:20]}
def collect(sql,state):
bots=[b for b in state.get('bots',[]) if b.get('autonomy',{}).get('managed') and type(b.get('autonomy',{}).get('characterGuid')) is int]
allowed={b['autonomy']['characterGuid']:b for b in bots}
rows={}
for line in sql('SELECT guid,quests,abandoned,loot_events,loot_items,party_kills FROM acore_auth.azerothai_outcomes').splitlines():
fields=list(map(int,line.split('\t')))
if fields[0] in allowed:rows[fields[0]]=dict(zip(COUNTERS,fields[1:]+[0]))
rewarded={}
if allowed:
guids=','.join(map(str,allowed))
for line in sql('SELECT guid,COUNT(*) FROM acore_characters.character_queststatus_rewarded WHERE guid IN ('+guids+') GROUP BY guid').splitlines():
guid,count=map(int,line.split('\t'));rewarded[guid]=count
result=[]
for guid,b in allowed.items():
counters=rows.get(guid,dict.fromkeys(COUNTERS,0));counters['rewarded']=rewarded.get(guid,0)
result.append({'guid':guid,'name':b['name'],**counters})
return result
def performance():
# Both paths are fixed; no user-controlled shell input and no credentials.
for path in ['/azerothcore/env/dist/logs/pmon_tick.json','/azerothcore/pmon_tick.json']:
p=subprocess.run(['docker','exec','ac-worldserver','cat',path],capture_output=True,text=True,timeout=10)
if p.returncode==0:return normalize_performance(json.loads(p.stdout))
return None
+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/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'}); const result=spawnSync(process.execPath,['node_modules/typescript/bin/tsc','core/realm-outcomes.ts','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/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); process.exit(spawnSync(process.execPath,['--test','tests/realm-outcomes.test.cjs','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);
+14
View File
@@ -0,0 +1,14 @@
from pathlib import Path
from lupa import LuaRuntime
l=LuaRuntime();l.execute('''hooks={};timers={};sql={};function AuthDBExecute(s)table.insert(sql,s)end
function RegisterPlayerEvent(id,fn)hooks[id]=fn end
function CreateLuaEvent(fn) table.insert(timers,fn)end
function PrintInfo()end
p={};function p:IsPlayerbot()return true end;function p:GetGUIDLow()return 9 end
human={};function human:IsPlayerbot()return false end
''')
l.execute((Path(__file__).resolve().parents[1]/'ale/azerothai_outcomes.lua').read_text())
l.execute('hooks[32](32,p,nil,3);hooks[54](54,p);hooks[38](38,p);hooks[32](32,human,nil,99);timers[1]()')
assert 'VALUES (9,1,1,1,3,0)' in l.eval('sql[#sql]')
n=l.eval('#sql');l.execute('timers[1]()');assert l.eval('#sql')==n
print('Native counters exclude humans, aggregate quantities and flush once')
+9
View File
@@ -0,0 +1,9 @@
import importlib.util
from pathlib import Path
spec=importlib.util.spec_from_file_location('evidence',Path(__file__).resolve().parents[1]/'scripts/realm-evidence.py')
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
p=m.normalize_performance({'generatedAt':10,'enabled':True,'fullTickCount':2,'fullTickTotalTime':4000,'metrics':[{'type':'action','rows':[{'name':'move','count':2,'totalTime':3000,'avgTime':1500,'maxTime':2000,'timePerTick':1500}]}]})
assert p['averageTickMs']==2 and p['rows'][0]['averageMs']==1.5
assert m.normalize_performance({'fullTickCount':0})['averageTickMs']==0
assert m.normalize_performance({'fullTickTotalTime':float('nan')})['averageTickMs']==0
print('Native profiler microsecond conversion and malformed measurements passed')
+1
View File
@@ -9,3 +9,4 @@ test('report closes at six UTC, records partial baseline and observed totals',()
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('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);}); 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);});
test('enabling failure tracking does not backfill old failed party actions',()=>{const s=state();const a=observeInsights(s,undefined,now);s.community.feed=[{id:'party-old',kind:'party',status:'failed',at:new Date((now-600)*1000).toISOString(),names:['A','B']},{id:'party-new',kind:'party',status:'failed',at:new Date((now+20)*1000).toISOString(),names:['A','B']}];const b=observeInsights(s,a.cursor,now+60);assert.deepEqual(b.events.map(e=>e.id),['social:party-new']);});
+6
View File
@@ -0,0 +1,6 @@
const {test}=require('node:test'),assert=require('node:assert/strict');
const {outcomeChanges,attentionItems}=require('../.test-build/realm-outcomes.js');
const row={guid:1,name:'A',quests:10,abandoned:1,lootEvents:4,lootItems:10,partyKills:2,rewarded:8};
test('outcome baseline excludes history, reports native deltas, handles resets without invented gains',()=>{const first=outcomeChanges([row],undefined,100);assert.equal(first.events.length,0);const next=outcomeChanges([{...row,lootItems:14,rewarded:9}],first.baseline,160);assert.deepEqual(next.events.map(e=>[e.kind,e.amount]),[['lootItems',4],['rewarded',1]]);assert.equal(outcomeChanges(next.baseline.rows,next.baseline,220).events.length,0);assert.equal(outcomeChanges([{...row,lootItems:0}],next.baseline,280).events.length,0);});
test('attention skips false population alarms when roster is stale and prioritizes resource pressure',()=>{const s={bots:[],aleRosterAt:0};const h=[{host:'ubuntu',at:1000,memoryAvailableMB:100,memoryTotalMB:8000,diskFreeGB:20,cpuPercent:10},{host:'windows',at:1000,memoryAvailableMB:2000,memoryTotalMB:16000,diskFreeGB:20,cpuPercent:10}];const a=attentionItems(s,1000,1000,1000,{at:1000,enabled:true},[],h);assert.equal(a[0].key,'memory:ubuntu');assert.equal(a.some(x=>x.key==='population'),false);});
test('resolved old recovery failures do not remain actionable',()=>{const b={id:'a',name:'A',status:'online',autonomy:{managed:true,characterGuid:1},world:{at:1000,stationarySince:990},lastDecision:{action:'quest'}};const a=attentionItems({bots:[b]},1000,1000,1000,null,[{id:1,guid:1,name:'A',at:900,status:'no_progress'}],[]);assert.equal(a.some(x=>x.key.startsWith('recovery:')),false);});
+6 -1
View File
@@ -3,7 +3,7 @@ from lupa import LuaRuntime
code=(Path(__file__).resolve().parents[1]/'ale/azerothai_recovery.lua').read_text() code=(Path(__file__).resolve().parents[1]/'ale/azerothai_recovery.lua').read_text()
setup=''' setup='''
now=1000;os.time=function()return now end;attempts=0;expired=0;last=nil;active=true;player={x=0};ai={};captured={} now=1000;os.time=function()return now end;attempts=0;expired=0;last=nil;active=true;player={x=0};ai={};captured={}
function AuthDBExecute(sql) if string.find(sql,'INSERT INTO',1,true) then last=now end end function AuthDBExecute(sql) table.insert(captured,sql);if string.find(sql,'INSERT INTO azerothai_recovery_log',1,true) then last=now end end
function AuthDBQuery(sql) if string.find(sql,'recovery_log',1,true) then return last and now-last<1800 and {} or nil end;return active and {} or nil end function AuthDBQuery(sql) if string.find(sql,'recovery_log',1,true) then return last and now-last<1800 and {} or nil end;return active and {} or nil end
function GetPlayerByName(n) return n=='Test' and player or nil end function GetPlayerByName(n) return n=='Test' and player or nil end
function CreateLuaEvent(fn) tick=fn end function CreateLuaEvent(fn) tick=fn end
@@ -55,3 +55,8 @@ print('Intent, movement, health progress, active identity and stale input guards
l=new();l.execute('player.group={GetMembers=function()return {{IsPlayerbot=function()return false end}} end,GetMembersCount=function()return 1 end};step(0);step(601);assert(attempts==0)') l=new();l.execute('player.group={GetMembers=function()return {{IsPlayerbot=function()return false end}} end,GetMembersCount=function()return 1 end};step(0);step(601);assert(attempts==0)')
l=new();l.execute('player.motion=7;step(0);step(601);assert(attempts==1 and expired==1)') l=new();l.execute('player.motion=7;step(0);step(601);assert(attempts==1 and expired==1)')
print('Human group exclusion and stale flight recovery passed') print('Human group exclusion and stale flight recovery passed')
l=new();l.execute("step(0);step(600);player.x=8;step(30);assert(string.find(captured[#captured], '726573756D6564'))")
l=new();l.execute("step(0);step(600);step(180);assert(string.find(captured[#captured], '6E6F5F70726F6772657373'))")
l=new();l.execute("step(0);step(600);player.dead=true;step(30);assert(string.find(captured[#captured], '756E7665726966696564'))")
print('Recovery outcomes distinguish native progress, no progress, and unavailable residents')