diff --git a/ale/azerothai_outcomes.lua b/ale/azerothai_outcomes.lua new file mode 100644 index 0000000..2cca248 --- /dev/null +++ b/ale/azerothai_outcomes.lua @@ -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') diff --git a/ale/azerothai_recovery.lua b/ale/azerothai_recovery.lua index 8b28f55..e95b60b 100644 --- a/ale/azerothai_recovery.lua +++ b/ale/azerothai_recovery.lua @@ -1,6 +1,6 @@ -- Conservative recovery for managed residents with fresh quest/explore intent. -- Native position/progress is sampled independently of dashboard summaries. -local intents, observations = {}, {} +local intents, observations, pendingResults = {}, {}, {} local LIMIT = 600 local COOLDOWN = 1800 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, name VARCHAR(64) NOT NULL,at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 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) local nextIntents={} for _,r in ipairs(rows) do @@ -31,6 +50,7 @@ local function safe(p,a) end local function sample() local now=os.time() + checkResults(now) for guid,r in pairs(intents) do 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 @@ -55,6 +75,7 @@ local function sample() local moved=a:DoSpecificAction('move random',true) 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)..')') + 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) end old.since=now diff --git a/app/api/realm-insights/route.ts b/app/api/realm-insights/route.ts index 5fdfd41..af0f064 100644 --- a/app/api/realm-insights/route.ts +++ b/app/api/realm-insights/route.ts @@ -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 {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)}); +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([ + 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_evidence (id TEXT PRIMARY KEY,at INTEGER,payload TEXT)'), 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 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'}}); + 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={};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){ 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 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 gate=await env.DB!.prepare('UPDATE insights_clock SET at=? WHERE id=1 AND at();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))); 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))); + 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(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)); await env.DB!.batch(statements); for(let start=Math.max(reportWindow(cursor.startedAt),reportWindow(now)-30*DAY);startspan{height:6px;width:6px;border-radius:100%;background:currentColor}.diagnostics .good{color:var(--diag-green)}.diagnostics .warning{color:var(--diag-amber)}.diagnostics .critical{color:var(--diag-red)}.diag-updated{display:flex;align-items:center;gap:7px;margin:14px 0 24px;font-size:12px;color:#91a5be;line-height:1.6}.diag-metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:24px}.diag-metric{padding:20px;background:#111e2d;border:1px solid #2b3d50;border-radius:12px;display:flex;flex-direction:column;gap:12px;min-width:0}.diag-metric-label{display:flex;align-items:center;gap:8px;font-size:13px;color:#a9bcd4}.diag-metric>strong{font-size:30px;font-weight:500;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.diag-metric>small{font-size:11px;line-height:1.6;color:#91a5be}.diag-section-title{display:flex;align-items:center;justify-content:space-between;margin:24px 0 14px;gap:10px}.diag-section-title .fineprint{margin:0}.diag-alerts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.diag-alert{display:flex;align-items:flex-start;gap:14px;text-align:left;padding:21px;background:#121f2d;border:1px solid #3d4652;border-radius:10px;transition:background .15s,border-color .15s}.diag-alert:hover{background:#1a2b3d;border-color:#7991ac}.diag-alert>div{flex:1;min-width:0}.diag-alert h3{color:#e1e9f4;margin:4px 0 8px}.diag-alert p,.diag-recovery p{color:#aebed1;font-size:13px;line-height:1.65}.diag-alert small{display:block;color:#9dc0f4;margin-top:12px}.diag-label{text-transform:uppercase;letter-spacing:1.5px;font-size:10px}.diag-links{display:flex;gap:12px;margin-top:24px;flex-wrap:wrap}.diag-links button{display:flex;gap:20px;align-items:center;padding:12px 16px;background:#17273a;border:1px solid #344a65;border-radius:8px;font-size:13px}.diag-clear{text-align:center;padding:40px 20px;background:#11251f55;border:1px solid #284c40;border-radius:12px}.diag-clear svg{margin:0 auto 15px;color:#7de3b4}.diag-clear p{color:#a9bacd;margin:10px auto 0;font-size:13px;line-height:1.7;max-width:600px}.diag-note{padding:16px 20px;border:1px solid #2d4057;border-radius:9px;background:#142237;color:#a6bad2;font-size:12px;line-height:1.8;margin-bottom:22px}.diag-toolbar{display:flex;gap:12px;margin:22px 0;flex-wrap:wrap}.diag-search{display:flex;align-items:center;gap:10px;flex:1;min-width:200px;background:#0e1826;border:1px solid #34475e;border-radius:8px;padding:11px 14px;color:#8ba4c4}.diag-search input{width:100%;outline:none;background:transparent;color:#dce5f1;font-size:14px}.diag-toolbar select{background:#142237;border:1px solid #34475e;border-radius:8px;padding:10px 14px;font-size:13px;max-width:100%}.diag-bot-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,340px),1fr));gap:16px}.diag-bot{padding:20px;border:1px solid #293c52;border-radius:12px;background:linear-gradient(135deg,#142334,#101b29)}.diag-bot-head{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.diag-bot-head>div{flex:1}.diag-bot-head small{font-size:11px;display:block;margin-top:5px}.diag-avatar{display:grid;place-items:center;width:40px;height:44px;background:#28394c;border:1px solid #526079;border-radius:8px;font:24px Georgia,serif;color:#e2c48f}.diag-chip{padding:5px 8px;border:1px solid #3a4b5d;border-radius:30px;font-size:10px;color:#9cafc6;background:#0d1825;white-space:nowrap}.diag-bot-values{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:22px 0}.diag-bot-values strong{display:block;font-size:26px;font-weight:500;font-variant-numeric:tabular-nums}.diag-bot-values small{font-size:10px;display:block;margin-top:6px}.diagnostics details{border-top:1px solid #293c52;padding-top:12px;font-size:12px;color:#aebed1}.diagnostics summary{cursor:pointer;color:#a6c9f9}.diagnostics details p{font-size:12px;line-height:1.7;margin-top:10px}.diag-text-link{display:flex;align-items:center;gap:8px;color:#9fc6ff;margin-top:12px}.diag-timeline{margin-top:24px}.diag-timeline>div{padding:16px 0;border-bottom:1px solid #26384d}.diag-timeline strong{display:block;margin-top:7px}.diag-recovery{padding:20px;border:1px solid #314359;border-radius:10px;background:#121f2d;display:grid;grid-template-columns:1fr auto;gap:14px}.diag-recovery>p,.diag-recovery>details{grid-column:1/-1}.diag-recovery small{display:block;margin-top:7px;font-size:11px}.diag-bars{height:150px;display:flex;align-items:flex-end;gap:4px;border-bottom:1px solid #39556b;background:repeating-linear-gradient(to top,transparent 0,transparent 36px,#233449 37px,#233449 38px)}.diag-bars>div{flex:1;max-width:30px;min-width:1px;background:linear-gradient(to top,#377599,#83d7c4);border-radius:3px 3px 0 0}.diag-table-scroll{overflow:auto;max-height:650px}.diag-table{border-collapse:collapse;width:100%;font-size:12px;font-variant-numeric:tabular-nums}.diag-table th{text-align:left;padding:15px 18px;color:#92a9c4;background:#122033;white-space:nowrap}.diag-table td{padding:15px 18px;border-top:1px solid #26384d;white-space:nowrap}.diag-table td:first-child{white-space:normal;min-width:230px;max-width:400px;overflow-wrap:anywhere}.diag-table small{display:block;font-size:10px;margin-top:5px}.diagnostics button:focus-visible,.diagnostics select:focus-visible,.diagnostics summary:focus-visible,.diag-search:focus-within{outline:2px solid #92bdf9;outline-offset:3px}@media(max-width:1100px){.diag-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.diag-alerts{grid-template-columns:1fr}}@media(max-width:767px){.diag-hero{padding:22px;align-items:flex-start;flex-direction:column}.diag-hero h2{font-size:25px}.diag-metric{padding:15px}.diag-metric>strong{font-size:25px}.diag-metric-label{font-size:12px}.diag-alert{padding:17px}.diag-section-title{align-items:flex-start;flex-direction:column}.diag-table td,.diag-table th{padding:12px}.diag-recovery{grid-template-columns:1fr}.diag-recovery .diag-chip{justify-self:start}.diag-updated{align-items:flex-start}} diff --git a/app/page.tsx b/app/page.tsx index 3809916..139a664 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,8 +1,9 @@ 'use client'; +import {RealmDiagnostics} from './realm-diagnostics'; import {RealmInsights} from './realm-insights'; import {CommunityPanel,OperationsPanel} from './community-panel'; import { useState,useEffect,useCallback } from 'react'; -import { LayoutDashboard,Users,BookOpen,Compass,Server,Activity,Brain,Sparkles,Plus,Play,Pause,ArrowRight,Shield,Terminal,ScrollText,Radio,RefreshCw,Heart,MapPin,Network,Copy,Send,House,Globe2,Moon,Sun,Skull,Clock3 } from 'lucide-react'; +import { 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 { Button } from '@/components/ui/button'; 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 { Toaster,toast } from 'sonner'; 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']; function Choice({value,onChange,items,label}:{value:string;onChange:(v:string)=>void;items:{value:string;label:string}[];label:string}){return } const options=(items:string[])=>items.map(value=>({value,label:value})); @@ -26,6 +27,7 @@ function Avatar({bot}:{bot:Bot}){return

{Icon&&}{title}

{action}
{children}} function Events({events}:{events:RealmEvent[]}){return
{events.length?[...events].reverse().map(e=>
{new Date(e.at).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}{e.kind}{e.message}
):
No events match this filter.
}
} export default function Home({readOnly=false}:{readOnly?:boolean}){ + const [focusName,setFocusName]=useState(''); const [state,setState]=useState(null),[page,setPage]=useState('Dashboard'),[error,setError]=useState(''),[busy,setBusy]=useState(false),[query,setQuery]=useState(''),[filter,setFilter]=useState('all'),[botId,setBotId]=useState(null),[createOpen,setCreateOpen]=useState(false),[recipe,setRecipe]=useState(null),[recipeOpen,setRecipeOpen]=useState(false),[confirm,setConfirm]=useState<{title:string;description:string;action:any}|null>(null),[capabilities,setCapabilities]=useState({}),[monitor,setMonitor]=useState(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');}},[]); 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{}}; 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]); - 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 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 CharacterStatusLocationRecipe{bots.map(b=>setBotId(b.id)}>
Lv. {b.level} · {b.race} {b.role}
{b.status}{b.zone}{b.personality.name.replace('The ','')}
)}
} @@ -43,7 +45,8 @@ export default function Home({readOnly=false}:{readOnly?:boolean}){
{page==='GM Console'?'THE WORLD IS YOUR SANDBOX':'AZEROTHAI CONTROL PLANE'}

{page==='Dashboard'?'Your realm, alive.':page}

{({'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]}

{readOnly?:page==='Bot Recipes'?:<>}
{error&&
{error}
} {!state?
{[1,2,3,4].map(n=>)}
:<> - {(page==='Nightly Report'||page==='Bot Activity')&&} + {['Needs attention','Gameplay Results','Recovery Results','Bot Performance'].includes(page)&&} + {(page==='Nightly Report'||page==='Bot Activity')&&} {page==='Dashboard'&&<>
{[[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)=>
{label}
{value}
{label==='Active bots'&&state.settings.connector!=='simulation'?'Acknowledged by the game bridge':note}
)}
YOUR NEXT ADVENTURE

{state.director.experience}

{state.director.zone} · {state.director.brief}

nav('Bots')}>{readOnly?'View bots':'Manage bots'} }>{botTable(state.bots.slice(0,5))}
{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
{zone}b.status==='online').length)*100}%`}}/>{n}
})}

Bot locations from the selected connector. Simulation locations are illustrative.

{state.settings.provider==='deterministic'?'Personality simulator':state.settings.model}

{state.settings.provider==='deterministic'?'No model required. Try a decision from any online bot.':'Local model selected. Each decision includes identity and memories.'}

nav('Event Stream')}>View stream }>{}

Set the scene. Announce a gathering. Move a character to the next chapter of their story.

<GM> AzerothAI

Welcome to your world.

.announce An adventure awaits...
} {page==='World Monitor'&&} {page==='Bots'&&<>
setQuery(e.target.value)}/>{visible.length} characters
{visible.length?botTable(visible):
No matching bots. Create a character or adjust your filters.
}
} diff --git a/app/realm-diagnostics.tsx b/app/realm-diagnostics.tsx new file mode 100644 index 0000000..d4d7a3c --- /dev/null +++ b/app/realm-diagnostics.tsx @@ -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}; +type Data={collectedAt:number|null;attention:Attention[];outcomes:{at:number|null;startedAt:number|null;windowStart:number;totals:Record;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={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
{label}
{value}{note}
} +export function RealmDiagnostics({page,onNavigate,initialQuery=''}:{page:string;initialQuery?:string;onNavigate:(page:string,name?:string)=>void}){ + const [data,setData]=useState(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
{error||'Gathering realm evidence…'}
; + 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
EVIDENCE FROM YOUR REALM

{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.'}

{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.'}

{stale?'Collection delayed':'Collector active'}

Last collection {stamp(data.collectedAt)} · View refreshes every 15 seconds

{error&&
{error}
} + {page==='Needs attention'&&<>

Follow-up queue

Critical signals first
{data.attention.length?
{data.attention.map(a=>)}
:

No current attention signals

The monitored checks are clear. This is not proof that every gameplay path is bug-free.

}
{['Gameplay Results','Recovery Results','Bot Performance'].map(p=>)}
} + {page==='Gameplay Results'&&<>
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: {metrics.quests??0}. Party actions rejected: {metrics.partyFailed??0}. Gaps or a server reload can miss unflushed events.
{visibleBots.map(b=>
{b.name[0]}

{b.name}

{b.zone}
{b.grouped?'Grouped':'Solo / unknown'}
{b.totals.rewarded}Quest rewards
{b.totals.lootItems}Items looted
{b.totals.partyKills}Shared kills
Evidence details

Objectives completed: {b.totals.quests} · Abandoned: {b.totals.abandoned} · Loot collections: {b.totals.lootEvents}

Observed bot companions: {b.groupBots.filter(n=>n!==b.name).join(', ')||'None reported'}

)}
{!visibleBots.length&&
No residents match your filter.
}
Recent evidence ({o.recent.length}){o.recent.map(e=>
{stamp(e.at)}{e.name}

{e.detail} · +{e.amount}

)}
} + {page==='Recovery Results'&&<>
{['resumed','no_progress','pending','unverified'].map(status=>r.status===status).length} note="Among the latest 100 retained attempts" tone={status==='resumed'?'good':status==='no_progress'?'warning':''} icon={status==='resumed'?CheckCircle2:Clock3}/>)}

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.

{attempts.map((r,i)=>

{r.name}

{stamp(r.at)}
{labels[r.status]}

{r.result}

Attempt details

{r.detail}

Checked: {stamp(r.checkedAt)}
)}
{!attempts.length&&

No matching recovery attempts

Results appear after the native guard attempts a recovery. No intervention is forced to populate this view.

}} + {page==='Bot Performance'&&<>
{(!perf||Date.now()/1000-perf.at>720)&&
Profiler data is missing or stale. Measurements below are not current.
}

Average full-tick history

Milliseconds · cumulative average
{data.performance.history.slice(-48).map(p=>
x.averageTickMs)))+'%'}}/>)}

{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.

Most expensive measured operations

Sorted by accumulated time
{perf?.rows.map((r,i)=>)}
OperationCallsMeanMaximumPer full tick
{r.name}{r.category}{Math.round(r.calls).toLocaleString()}{r.averageMs.toFixed(3)} ms{r.maxMs.toFixed(3)} ms{r.perTickMs.toFixed(3)} ms
{!perf?.rows.length&&

Waiting for the first native profiler export.

}
} +
; +} diff --git a/app/realm-insights.tsx b/app/realm-insights.tsx index 435d130..1f39a52 100644 --- a/app/realm-insights.tsx +++ b/app/realm-insights.tsx @@ -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[]}; const stamp=(at:number|null)=>at?new Date(at*1000).toLocaleString():'Not yet observed'; const metrics:Record={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(null),[error,setError]=useState(''),[selected,setSelected]=useState('current'),[query,setQuery]=useState(''),[attention,setAttention]=useState(false); +export function RealmInsights({mode,initialQuery=''}:{mode:string;initialQuery?:string}){ + const [data,setData]=useState(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);};},[]); if(!data)return
{error||'Loading realm observations…'}
; const report=selected==='current'?data.current:data.reports.find(r=>String(r.start)===selected)??data.current; diff --git a/core/realm-insights.ts b/core/realm-insights.ts index fdc2c04..088c2b7 100644 --- a/core/realm-insights.ts +++ b/core/realm-insights.ts @@ -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 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; const at=Date.parse(e.at)/1000;if(!Number.isFinite(at)||atnow+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); diff --git a/core/realm-outcomes.ts b/core/realm-outcomes.ts new file mode 100644 index 0000000..e5c6591 --- /dev/null +++ b/core/realm-outcomes.ts @@ -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; +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.memoryAvailableMB90)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();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')); +} diff --git a/docs/realm-insights.md b/docs/realm-insights.md index 4ac2ca2..39b37d0 100644 --- a/docs/realm-insights.md +++ b/docs/realm-insights.md @@ -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. `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. diff --git a/scripts/operations-agent.py b/scripts/operations-agent.py index efbf4c6..eb1062f 100644 --- a/scripts/operations-agent.py +++ b/scripts/operations-agent.py @@ -81,11 +81,18 @@ except Exception as e:maintenance.append('Recovery check needs attention: '+type 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])) + 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') + 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__) - 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 maintenance.append('Realm report sampled') except Exception as e:maintenance.append('Realm report needs attention: '+type(e).__name__) diff --git a/scripts/realm-evidence.py b/scripts/realm-evidence.py new file mode 100644 index 0000000..7e7be98 --- /dev/null +++ b/scripts/realm-evidence.py @@ -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 diff --git a/scripts/test-core.mjs b/scripts/test-core.mjs index a93304f..c639b53 100644 --- a/scripts/test-core.mjs +++ b/scripts/test-core.mjs @@ -1,6 +1,6 @@ import {spawnSync} from 'node:child_process'; 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); 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); diff --git a/tests/outcomes-lua.test.py b/tests/outcomes-lua.test.py new file mode 100644 index 0000000..4f9f7f2 --- /dev/null +++ b/tests/outcomes-lua.test.py @@ -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') diff --git a/tests/realm-evidence.test.py b/tests/realm-evidence.test.py new file mode 100644 index 0000000..b380f30 --- /dev/null +++ b/tests/realm-evidence.test.py @@ -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') diff --git a/tests/realm-insights.test.cjs b/tests/realm-insights.test.cjs index 22d30e8..1fab2fd 100644 --- a/tests/realm-insights.test.cjs +++ b/tests/realm-insights.test.cjs @@ -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('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']);}); diff --git a/tests/realm-outcomes.test.cjs b/tests/realm-outcomes.test.cjs new file mode 100644 index 0000000..d602210 --- /dev/null +++ b/tests/realm-outcomes.test.cjs @@ -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);}); diff --git a/tests/recovery-lua.test.py b/tests/recovery-lua.test.py index 7de045c..b10758f 100644 --- a/tests/recovery-lua.test.py +++ b/tests/recovery-lua.test.py @@ -3,7 +3,7 @@ from lupa import LuaRuntime code=(Path(__file__).resolve().parents[1]/'ale/azerothai_recovery.lua').read_text() setup=''' 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 GetPlayerByName(n) return n=='Test' and player or nil 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.motion=7;step(0);step(601);assert(attempts==1 and expired==1)') 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')