From 9d586f4c44ea78d230e60cea10113d56529a744d Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 22 Sep 2026 20:46:02 -0400 Subject: [PATCH] Add nightly realm reports and observed bot activity explanations --- ale/azerothai_bridge.lua | 4 ++- app/api/realm-insights/route.ts | 51 ++++++++++++++++++++++++++++++++ app/page.tsx | 4 ++- app/realm-insights.tsx | 23 +++++++++++++++ core/community.ts | 2 +- core/realm-insights.ts | 52 +++++++++++++++++++++++++++++++++ core/types.ts | 2 +- docs/realm-insights.md | 11 +++++++ scripts/operations-agent.py | 11 +++++++ scripts/spectator-server.py | 6 ++++ scripts/test-core.mjs | 4 +-- scripts/test-spectator.py | 8 +++++ tests/realm-insights.test.cjs | 11 +++++++ 13 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 app/api/realm-insights/route.ts create mode 100644 app/realm-insights.tsx create mode 100644 core/realm-insights.ts create mode 100644 docs/realm-insights.md create mode 100644 tests/realm-insights.test.cjs diff --git a/ale/azerothai_bridge.lua b/ale/azerothai_bridge.lua index 98c3a33..ac1e06f 100644 --- a/ale/azerothai_bridge.lua +++ b/ale/azerothai_bridge.lua @@ -114,7 +114,7 @@ local function player_payload(player) healthPct=player:GetHealthPct(), observedAt=os.time(),team=player:GetTeam(),x=player:GetX(),y=player:GetY(), guildId=player:GetGuildId(),guildName=player:GetGuildName() or "",guildRank=player:GetGuildRank(), - inCombat=player:IsInCombat(), + inCombat=player:IsInCombat(),casting=player:IsCasting(),taxi=player:IsTaxi(),movementType=player:GetMovementType(), isBot=isBot } end @@ -143,6 +143,8 @@ end local function world_payload(player) local p=player_payload(player) + local ai=player:IsPlayerbot() and player:GetBotAI() or nil + p.humanControlled=ai and ai:HasGameClientMaster() or false local g=player:GetGroup() p.groupId=g and tostring(g:GetGUID()) or "" local members={} diff --git a/app/api/realm-insights/route.ts b/app/api/realm-insights/route.ts new file mode 100644 index 0000000..5fdfd41 --- /dev/null +++ b/app/api/realm-insights/route.ts @@ -0,0 +1,51 @@ +import {env} from 'cloudflare:workers'; +import {z} from 'zod'; +import {D1Store} from '@/core/store'; +import type {RuntimeConfig} from '@/core/types'; +import {observeInsights,activityReason,reportWindow,summarize,DAY,type Cursor,type Evidence} from '@/core/realm-insights'; +const recovery=z.object({id:z.number().int().positive(),guid:z.number().int().positive(),name:z.string().max(64),at:z.number().finite(),detail:z.string().max(255)}); +async function ensure(){await env.DB!.batch([ + env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_cursor (id INTEGER PRIMARY KEY,payload TEXT)'), + env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_evidence (id TEXT PRIMARY KEY,at INTEGER,payload TEXT)'), + env.DB!.prepare('CREATE INDEX IF NOT EXISTS insights_evidence_at ON insights_evidence(at)'), + env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_samples (at INTEGER PRIMARY KEY)'), + env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_reports (start INTEGER PRIMARY KEY,payload TEXT)'), + env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_recoveries (id INTEGER PRIMARY KEY,payload TEXT)'), + env.DB!.prepare('CREATE TABLE IF NOT EXISTS insights_clock (id INTEGER PRIMARY KEY,at INTEGER)'), + env.DB!.prepare('INSERT OR IGNORE INTO insights_clock VALUES (1,0)')]);} +async function report(start:number,end:number,startedAt:number){ + const rows=await env.DB!.prepare('SELECT payload FROM insights_evidence WHERE at>=? AND at(); + const samples=await env.DB!.prepare('SELECT COUNT(*) AS n FROM insights_samples WHERE at>=? AND at(); + return summarize(rows.results.map(r=>JSON.parse(r.payload)),start,end,startedAt,samples?.n??0); +} +export async function GET(){ + await ensure();const now=Math.floor(Date.now()/1000),s=await new D1Store().load(); + const row=await env.DB!.prepare('SELECT payload FROM insights_cursor WHERE id=1').first<{payload:string}>();const cursor:Cursor|undefined=row?JSON.parse(row.payload):undefined; + const history=await env.DB!.prepare('SELECT payload FROM insights_reports ORDER BY start DESC LIMIT 30').all<{payload:string}>(); + const recoveries=await env.DB!.prepare('SELECT payload FROM insights_recoveries ORDER BY id DESC LIMIT 100').all<{payload:string}>();const attempts=recoveries.results.map(r=>JSON.parse(r.payload)); + return Response.json({collectedAt:cursor?.at??null,startedAt:cursor?.startedAt??null,nextReportAt:reportWindow(now)+DAY,current:await report(reportWindow(now),now+1,cursor?.startedAt??now),reports:history.results.map(r=>JSON.parse(r.payload)),activity:s.bots.filter(b=>b.autonomy?.managed&&b.status==='online').map(b=>({name:b.name,zone:b.zone,action:b.lastDecision?.action??'unknown',observedAt:b.world?.at??null,stationarySince:b.world?.stationarySince??null,lastProgressAt:cursor?.bots[b.id]?.lastProgressAt??null,...activityReason(b,now),recoveries:attempts.filter(r=>r.guid===b.autonomy?.characterGuid).slice(0,3).map(({at,detail})=>({at,detail}))}))},{headers:{'Cache-Control':'no-store'}}); +} +export async function POST(request:Request){ + const token=(env as unknown as RuntimeConfig).AUTONOMY_TOKEN;if(!token||request.headers.get('authorization')!==`Bearer ${token}`)return Response.json({error:'Unauthorized'},{status:401}); + try{ + const raw=await request.text();if(raw.length>50000)throw new Error('Payload too large');const body=z.object({at:z.number().finite(),recoveries:z.array(recovery).max(100)}).strict().parse(JSON.parse(raw)); + const now=Math.floor(Date.now()/1000);if(Math.abs(now-body.at)>180)throw new Error('Stale sample');await ensure(); + const gate=await env.DB!.prepare('UPDATE insights_clock SET at=? WHERE id=1 AND at();const old:Cursor|undefined=previous?JSON.parse(previous.payload):undefined; + const {cursor,events}=observeInsights(await new D1Store().load(),old,now); + const statements=events.map(e=>env.DB!.prepare('INSERT OR IGNORE INTO insights_evidence VALUES (?,?,?)').bind(e.id,e.at,JSON.stringify(e))); + for(const r of body.recoveries){ + if(r.at>now+60)continue; + statements.push(env.DB!.prepare('INSERT OR IGNORE INTO insights_recoveries VALUES (?,?)').bind(r.id,JSON.stringify(r))); + if(r.at>=cursor.startedAt){const e:Evidence={id:'recovery:'+r.id,at:r.at,kind:'recoveries',name:r.name,amount:1,detail:'Native recovery attempted'};statements.push(env.DB!.prepare('INSERT OR IGNORE INTO insights_evidence VALUES (?,?,?)').bind(e.id,e.at,JSON.stringify(e)));} + } + statements.push(env.DB!.prepare('INSERT INTO insights_cursor VALUES (1,?) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload').bind(JSON.stringify(cursor)),env.DB!.prepare('INSERT OR IGNORE INTO insights_samples VALUES (?)').bind(now)); + await env.DB!.batch(statements); + for(let start=Math.max(reportWindow(cursor.startedAt),reportWindow(now)-30*DAY);startvoid;items:{value:string;label:string}[];label:string}){return } const options=(items:string[])=>items.map(value=>({value,label:value})); @@ -42,6 +43,7 @@ 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')&&} {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-insights.tsx b/app/realm-insights.tsx new file mode 100644 index 0000000..435d130 --- /dev/null +++ b/app/realm-insights.tsx @@ -0,0 +1,23 @@ +'use client'; +import {useEffect,useState} from 'react'; +import {Input} from '@/components/ui/input'; +import type {summarize} from '@/core/realm-insights'; +type Report=ReturnType; +type Activity={name:string;zone:string;action:string;label:string;detail:string;observedAt:number|null;lastProgressAt:number|null;recoveries:{at:number;detail:string}[]}; +type Insights={collectedAt:number|null;startedAt:number|null;nextReportAt:number;current:Report;reports:Report[];activity:Activity[]}; +const stamp=(at:number|null)=>at?new Date(at*1000).toLocaleString():'Not yet observed'; +const metrics:Record={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); + 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; + const rows=data.activity.filter(b=>`${b.name} ${b.zone} ${b.label}`.toLowerCase().includes(query.toLowerCase())&&(!attention||['Possible stall','Telemetry delayed'].includes(b.label))); + return <>

Last collection: {stamp(data.collectedAt)} · Refreshes every 15 seconds.

{error&&

{error}; showing the last available observations.

}{(!data.collectedAt||Date.now()/1000-data.collectedAt>180)&&

Collection is pending or delayed. Report totals may be incomplete.

} + {mode==='Nightly Report'?<> +

{stamp(report.start)} — {selected==='current'?'now':stamp(report.end)}

Reports close daily at 06:00 UTC. Next close: {stamp(data.nextReportAt)}. Reports are kept for 30 days.

{report.partial?'Partial period · ':''}Observations since {stamp(report.observedSince)} · {report.samples} minute samples. Gaps can miss activity; these are observed changes, not a complete game log.

+
{Object.entries(metrics).map(([key,label])=>
{label}
{report.totals[key]??0}
)}
+

Realm highlights

{report.highlights.length?report.highlights.map(e=>
{e.name}

{e.detail}

{stamp(e.at)}
):

No changes recorded in this period yet.

}

Exchanges count confirmed bot replies, excluding synthetic openers and private player messages. Positive bonds need not be mutual friendships. Arrivals mean first observed online, not necessarily newly created. Recovery attempts do not prove recovery succeeded.

+ :<>
setQuery(e.target.value)}/>

{rows.length} of {data.activity.length} online residents. Progress means observed movement, level gain, or health change. Intentions describe plans, not completed game actions.

{rows.map(b=>

{b.name}

{b.label}

{b.zone} · Intent: {b.action}

{b.detail}

Game observation: {stamp(b.observedAt)}
Last observed progress: {stamp(b.lastProgressAt)}

Recent recovery attempts ({b.recoveries.length}){b.recoveries.length?b.recoveries.map((r,i)=>

{stamp(r.at)} · {r.detail}

):

No retained attempts. Recovery requires ten minutes without native progress, safe conditions, and an expired 30-minute cooldown.

}
)}
{!rows.length&&

No residents match this filter.

}} + ; +} diff --git a/core/community.ts b/core/community.ts index 149d592..8794d73 100644 --- a/core/community.ts +++ b/core/community.ts @@ -24,7 +24,7 @@ export function observePresence(s:State,b:Bot,p:Record){ if(typeof p.team!=='number'||typeof p.x!=='number'||typeof p.y!=='number')return; const old=b.world; const stationary=old&&old.mapId===p.mapId&&Math.hypot(old.x-p.x,old.y-p.y)<3; - b.world={at,team:p.team,guildId:Number(p.guildId)||0,guildName:String(p.guildName||''),guildRank:Number(p.guildRank)||0,groupId:String(p.groupId||''),groupNames:String(p.groupNames||'').split(',').filter(Boolean),nearby:String(p.nearbyBots||'').split(',').filter(Boolean),inCombat:p.inCombat===true,x:p.x,y:p.y,mapId:Number(p.mapId)||0,stationarySince:stationary?(old.stationarySince??old.at):at}; + b.world={casting:p.casting===true,taxi:p.taxi===true,humanControlled:p.humanControlled===true,movementType:typeof p.movementType==='number'?p.movementType:undefined,healthPct:typeof p.healthPct==='number'?p.healthPct:undefined,at,team:p.team,guildId:Number(p.guildId)||0,guildName:String(p.guildName||''),guildRank:Number(p.guildRank)||0,groupId:String(p.groupId||''),groupNames:String(p.groupNames||'').split(',').filter(Boolean),nearby:String(p.nearbyBots||'').split(',').filter(Boolean),inCombat:p.inCombat===true,x:p.x,y:p.y,mapId:Number(p.mapId)||0,stationarySince:stationary?(old.stationarySince??old.at):at}; const c=community(s); if(b.world.guildId&&!c.guilds.some(g=>g.id===b.world!.guildId))c.guilds.push({id:b.world.guildId,name:b.world.guildName,goal:['quest','support','explore'][b.world.guildId%3] as 'quest'|'support'|'explore',progress:0,target:20}); if(old&&old.mapId!==b.world.mapId&&b.world.guildId){ diff --git a/core/realm-insights.ts b/core/realm-insights.ts new file mode 100644 index 0000000..fdc2c04 --- /dev/null +++ b/core/realm-insights.ts @@ -0,0 +1,52 @@ +import type {Bot,State} from './types'; +export const DAY=86400,BOUNDARY=6*3600; +export const reportWindow=(at:number)=>Math.floor((at-BOUNDARY)/DAY)*DAY+BOUNDARY; +export type Evidence={id:string;at:number;kind:string;name:string;amount:number;detail:string}; +export type Progress={level:number;status:string;seenOnline:boolean;x?:number;y?:number;map?:number;health?:number;lastProgressAt?:number}; +export type Cursor={at:number;startedAt:number;bots:Record;bonds:string[];guilds:Record;social:string[]}; +export function observeInsights(s:State,previous:Cursor|undefined,now:number){ + const events:Evidence[]=[];const cursor:Cursor={at:now,startedAt:previous?.startedAt??now,bots:{},bonds:[],guilds:{},social:[]}; + const add=(id:string,kind:string,name:string,amount:number,detail:string,at=now)=>events.push({id,at,kind,name,amount,detail}); + const managed=s.bots.filter(b=>b.autonomy?.managed),ids=new Set(managed.map(b=>b.id)); + for(const b of managed){ + const old=previous?.bots[b.id],w=b.world; + const fresh=!!w&&now-w.at<120; + const moved=fresh&&old?.x!==undefined&&(old.map!==w!.mapId||Math.hypot(w!.x-old.x,w!.y-(old.y??w!.y))>=3); + const advanced=!!old&&(b.level>old.level||(fresh&&old.health!==undefined&&w!.healthPct!==undefined&&w!.healthPct!==old.health)); + cursor.bots[b.id]={level:b.level,status:b.status,seenOnline:!!old?.seenOnline||b.status==='online',x:fresh?w!.x:old?.x,y:fresh?w!.y:old?.y,map:fresh?w!.mapId:old?.map,health:fresh?w!.healthPct:old?.health,lastProgressAt:moved||advanced?now:old?.lastProgressAt}; + if(previous){ + if(old&&b.level>old.level)add(`level:${b.id}:${b.level}`,'levels',b.name,b.level-old.level,`Level ${old.level} → ${b.level}`); + if(b.status==='dead'&&old?.status!=='dead'&&b.death?.verified)add(`death:${b.id}`,'deaths',b.name,1,`Level ${b.death.level} · ${b.death.zone}`); + if(b.status==='online'&&!old?.seenOnline)add(`arrival:${b.id}`,'arrivals',b.name,1,`First observed online at level ${b.level}`); + } + for(const r of b.relationships){if(r.affinity<=0||!ids.has(r.targetId))continue;const key=[b.id,r.targetId].sort().join(':');if(cursor.bonds.includes(key))continue;cursor.bonds.push(key);if(previous&&!previous.bonds.includes(key))add('bond:'+key,'bonds',`${b.name} & ${managed.find(x=>x.id===r.targetId)?.name}`,1,'First observed positive bond');} + } + for(const g of s.community?.guilds??[]){const old=previous?.guilds[g.id];cursor.guilds[g.id]={goal:g.goal,progress:g.progress};if(old&&old.goal===g.goal&&g.progress>old.progress)add(`guild:${g.id}:${g.goal}:${g.progress}`,'guild',g.name,g.progress-old.progress,`${g.goal} goal: ${g.progress} / ${g.target}`);} + for(const e of s.community?.feed??[]){ + if(e.status==='confirmed')cursor.social.push(e.id); + if(!previous||previous.social.includes(e.id)||e.status!=='confirmed'||e.id.endsWith(':proposal'))continue; + const at=Date.parse(e.at)/1000;if(!Number.isFinite(at)||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); + if(e.kind==='party'||e.kind==='recruit')add('social:'+e.id,e.kind==='party'?'parties':'recruits',e.names.join(' & '),1,e.kind==='party'?'Confirmed party invitation':'Confirmed guild admission',at); + } + return {cursor,events}; +} +export function activityReason(b:Bot,now:number){ + const w=b.world,action=b.lastDecision?.action; + if(b.status!=='online')return {label:b.status,detail:'This resident is not currently online.'}; + if(!w||now-w.at>120)return {label:'Telemetry delayed',detail:'Current activity cannot be confirmed from stale observations.'}; + if(w.inCombat)return {label:'In combat',detail:'The game reports active combat. Recovery is skipped.'}; + if(w.casting)return {label:'Casting',detail:'The game reports a spell cast. Recovery is skipped.'}; + if(w.taxi)return {label:'Traveling by taxi',detail:'The game reports taxi travel. Recovery is skipped.'}; + if(w.humanControlled)return {label:'Following player control',detail:'A human master controls this bot. Recovery is skipped.'}; + const idle=now-(w.stationarySince??w.at); + if(idle<90)return {label:'Recently moving',detail:`Movement observed recently; intended activity: ${action??'not recorded'}.`}; + if(action==='rest'||action==='socialize'||action==='trade')return {label:`Intends to ${action}`,detail:'Stationary activity may be intentional. This is an AI intention, not proof of the action completing; recovery is skipped.'}; + if(idle>=600&&(action==='quest'||action==='explore'))return {label:'Possible stall',detail:w.movementType===7?'Flight controller remains active; native recovery checks whether it is stale.':'No recent movement while intending to travel. The native guard checks progress, safety and cooldown before recovery.'}; + return {label:w.groupId?'Grouped / stationary':'Briefly stationary',detail:w.groupId?'May be waiting for companions; waiting is inferred, not confirmed.':'May be choosing a target or pausing between actions. No stall is established.'}; +} +export function summarize(events:Evidence[],start:number,end:number,startedAt:number,samples:number){ + const totals:Record={levels:0,deaths:0,arrivals:0,conversations:0,bonds:0,parties:0,recruits:0,guild:0,recoveries:0}; + for(const e of events)totals[e.kind]=(totals[e.kind]??0)+e.amount; + return {start,end,observedSince:Math.max(start,startedAt),partial:startedAt>start,samples,totals,highlights:events.filter(e=>['levels','deaths','arrivals','guild','bonds'].includes(e.kind)).slice(-40).reverse()}; +} diff --git a/core/types.ts b/core/types.ts index 76a07cd..238e514 100644 --- a/core/types.ts +++ b/core/types.ts @@ -10,7 +10,7 @@ export type DeathRecord = {id:string; at:string; zone:string; level:number; caus export type Home = {id:string; name:string; zone:string; type:HousingType; capacity:number; ownerIds:string[]; residentIds:string[]; storageSlots:number; safety:number; description:string}; export type Routine = {wakeHour:number; sleepHour:number; preferredZones:string[]}; export type AutonomyState = {managed:boolean; characterGuid?:number; accountId?:number; observedAt?:string; lastIntentAt?:string; nextIntentAt?:string; lastAleCommandId?:string}; -export type WorldPresence = {at:number;team:number;guildId:number;guildName:string;guildRank:number;groupId:string;groupNames:string[];nearby:string[];inCombat:boolean;x:number;y:number;mapId:number;stationarySince?:number}; +export type WorldPresence = {casting?:boolean;taxi?:boolean;humanControlled?:boolean;movementType?:number;healthPct?:number;at:number;team:number;guildId:number;guildName:string;guildRank:number;groupId:string;groupNames:string[];nearby:string[];inCombat:boolean;x:number;y:number;mapId:number;stationarySince?:number}; export type SocialEntry = {id:string;at:string;kind:string;names:string[];text:string;affinityDelta?:number;status?:string}; export type GuildGoal = {id:number;name:string;goal:'quest'|'support'|'explore';progress:number;target:number}; export type Community = {feed:SocialEntry[];evidence:string[];guilds:GuildGoal[];partiesEnabled:boolean;guildsEnabled:boolean}; diff --git a/docs/realm-insights.md b/docs/realm-insights.md new file mode 100644 index 0000000..4ac2ca2 --- /dev/null +++ b/docs/realm-insights.md @@ -0,0 +1,11 @@ +# Realm reports and activity explanations + +Nightly Report and Bot Activity are available in both the admin and read-only visitor monitor. Visitor access is unchanged. + +The existing Ubuntu operations timer samples realm state once a minute through authenticated `POST /api/realm-insights`. The first sample establishes a baseline: existing deaths, levels and relationships are not presented as new events. Reports close at 06:00 UTC, finalized on the first successful sample after that boundary. The first period is partial. Thirty completed reports and 31 days of evidence/samples are retained in D1, included in existing verified realm backups. The latest 100 native recovery attempts are retained separately. + +Reports count observed level gains, verified deaths, first online appearances, confirmed bot reply exchanges, positive relationship pairs, party invitations, guild admissions and same-goal guild progress. First appearances need not mean newly created characters, invitations do not prove a party remains together, and positive bonds do not prove reciprocal friendship. Private player conversations and synthetic openers are excluded. Collection gaps may miss events. A report includes sample coverage; no historical event reconstruction is attempted. + +Activity explanations prioritize fresh native combat, casting, taxi travel and human control. AI decisions are labeled intentions. Stationary explanations and possible stalls are inferences, not diagnoses. Last observed progress uses movement, level gain or health change. Recovery attempts are shown without claiming success; the existing native guard still enforces its independent ten-minute progress window, safety exclusions and 30-minute cooldown. + +`GET /api/realm-insights` returns only public summaries and managed bot activity. The visitor gateway permits that fixed GET and rejects every mutation. Collection POST requires AUTONOMY_TOKEN. No credentials, native account IDs, memory text, private chat or raw command payloads are returned. diff --git a/scripts/operations-agent.py b/scripts/operations-agent.py index 9914b4c..efbf4c6 100644 --- a/scripts/operations-agent.py +++ b/scripts/operations-agent.py @@ -78,6 +78,17 @@ try: count=recovery.publish_recovery_intents(current,cfg) maintenance.append(f'Recovery intentions refreshed for {count} residents') except Exception as e:maintenance.append('Recovery check needs attention: '+type(e).__name__) +try: + recovery_rows=[] + try: + for line in sql('SELECT id,guid,name,UNIX_TIMESTAMP(at),detail FROM acore_auth.azerothai_recovery_log ORDER BY id DESC LIMIT 100').splitlines(): + fields=line.split('\t',4) + recovery_rows.append(dict(id=int(fields[0]),guid=int(fields[1]),name=fields[2],at=int(fields[3]),detail=fields[4])) + except Exception as e:maintenance.append('Recovery history unavailable: '+type(e).__name__) + request=urllib.request.Request(base+'/api/realm-insights',data=json.dumps({'at':time.time(),'recoveries':recovery_rows}).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+cfg['AUTONOMY_TOKEN']}) + with urllib.request.urlopen(request,timeout=20) as response:assert response.status==200 + maintenance.append('Realm report sampled') +except Exception as e:maintenance.append('Realm report needs attention: '+type(e).__name__) try: backup_status=json.loads((lab/'backups/latest-status.json').read_text()) maintenance.append(('Backup restored and verified: ' if backup_status.get('ok') else 'BACKUP NEEDS ATTENTION: ')+str(backup_status.get('at','unknown'))) diff --git a/scripts/spectator-server.py b/scripts/spectator-server.py index 8f7d8ac..6f5aabb 100644 --- a/scripts/spectator-server.py +++ b/scripts/spectator-server.py @@ -195,6 +195,12 @@ class Handler(BaseHTTPRequestHandler): at = data['rosterAt'] if data else 0 from datetime import datetime, timezone self.reply(200, json.dumps({'ok':bool(data),'ale':{'lastEventAt':datetime.fromtimestamp(at,timezone.utc).isoformat() if at else None,'activeRecently':bool(data and not data['stale'])},'events':[],'commands':{}}).encode()) + elif path == '/api/realm-insights': + try: + # Fixed read-only endpoint: public aggregate facts and bot activity only. + self.reply(200, local_get('/api/realm-insights')) + except Exception: + self.reply(503,b'{"error":"Realm insights unavailable"}') elif path == '/api/operations': try: health = json.loads(local_get('/api/operations')) diff --git a/scripts/test-core.mjs b/scripts/test-core.mjs index 3b1ff45..a93304f 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/service.ts','core/seed.ts','core/ale.ts','core/conversation.ts','core/autonomy.ts','--types','@cloudflare/workers-types,node','--outDir','.test-build','--module','commonjs','--target','es2022','--moduleResolution','node','--esModuleInterop','--skipLibCheck','--strict'],{stdio:'inherit'}); +const result=spawnSync(process.execPath,['node_modules/typescript/bin/tsc','core/realm-insights.ts','core/service.ts','core/seed.ts','core/ale.ts','core/conversation.ts','core/autonomy.ts','--types','@cloudflare/workers-types,node','--outDir','.test-build','--module','commonjs','--target','es2022','--moduleResolution','node','--esModuleInterop','--skipLibCheck','--strict'],{stdio:'inherit'}); if(result.status)process.exit(result.status); mkdirSync('.test-build',{recursive:true});writeFileSync('.test-build/package.json','{"type":"commonjs"}'); -process.exit(spawnSync(process.execPath,['--test','tests/core.test.cjs','tests/hardcore.test.cjs','tests/living-world.test.cjs','tests/ale.test.cjs','tests/brains.test.cjs','tests/conversation.test.cjs','tests/autonomy.test.cjs','tests/community.test.cjs'],{stdio:'inherit'}).status??1); +process.exit(spawnSync(process.execPath,['--test','tests/realm-insights.test.cjs','tests/core.test.cjs','tests/hardcore.test.cjs','tests/living-world.test.cjs','tests/ale.test.cjs','tests/brains.test.cjs','tests/conversation.test.cjs','tests/autonomy.test.cjs','tests/community.test.cjs'],{stdio:'inherit'}).status??1); diff --git a/scripts/test-spectator.py b/scripts/test-spectator.py index e3aff8f..68f567c 100644 --- a/scripts/test-spectator.py +++ b/scripts/test-spectator.py @@ -56,6 +56,14 @@ class SpectatorTests(unittest.TestCase): with patch.object(m.SNAPSHOT,'get',return_value={'state':{'bots':[]}}): with urllib.request.urlopen(base+'/api/state') as response: self.assertEqual(json.load(response),{'bots':[]}) + with patch.object(m,'local_get',return_value=b'{"reports":[]}') as upstream: + with urllib.request.urlopen(base+'/api/realm-insights') as response: + self.assertEqual(json.load(response),{'reports':[]}) + upstream.assert_called_once_with('/api/realm-insights') + with self.assertRaises(urllib.error.HTTPError) as error: + urllib.request.urlopen(urllib.request.Request(base+'/api/realm-insights',method='POST',data=b'{}')) + self.assertEqual(error.exception.code,405) + error.exception.close() for method in ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'TRACE']: with self.assertRaises(urllib.error.HTTPError) as error: urllib.request.urlopen(urllib.request.Request(base + '/api/actions', method=method, data=b'{}')) diff --git a/tests/realm-insights.test.cjs b/tests/realm-insights.test.cjs new file mode 100644 index 0000000..22d30e8 --- /dev/null +++ b/tests/realm-insights.test.cjs @@ -0,0 +1,11 @@ +const {test}=require('node:test');const assert=require('node:assert/strict'); +const {observeInsights,activityReason,reportWindow,summarize}=require('../.test-build/realm-insights.js'); +const now=200000; +function bot(id='A'){return {id,name:id,status:'online',level:1,relationships:[],autonomy:{managed:true},world:{at:now,x:0,y:0,mapId:0,healthPct:100,stationarySince:now-700},lastDecision:{action:'quest'}};} +function state(){return {bots:[bot(),bot('B')],community:{guilds:[],feed:[]}};} +test('insight baseline does not invent historical events; detects changed facts once',()=>{const s=state();let a=observeInsights(s,undefined,now);assert.equal(a.events.length,0);s.bots[0].level=3;s.bots[1].status='dead';s.bots[1].death={verified:true,level:1,zone:'Forest'};s.bots.push(bot('C'));const b=observeInsights(s,a.cursor,now+60);assert.deepEqual(b.events.map(e=>[e.kind,e.amount]),[['levels',2],['deaths',1],['arrivals',1]]);assert.equal(observeInsights(s,b.cursor,now+120).events.length,0);}); +test('report counts confirmed bot replies, unique positive pairs and same-goal guild progress',()=>{const s=state();s.community.guilds=[{id:'g',name:'Guild',goal:'Explore',progress:1,target:10}];const a=observeInsights(s,undefined,now);s.community.guilds[0].progress=4;s.bots[0].relationships=[{targetId:'B',affinity:5}];s.bots[1].relationships=[{targetId:'A',affinity:5}];s.community.feed=['social-1:reply','social-1:opener','whisper-1','social-2:reply'].map((id,i)=>({id,kind:'conversation',at:new Date((now+60)*1000).toISOString(),status:i===3?'pending':'confirmed',names:['A','B']}));const b=observeInsights(s,a.cursor,now+60);assert.equal(b.events.filter(e=>e.kind==='conversations').length,1);assert.equal(b.events.filter(e=>e.kind==='bonds').length,1);assert.equal(b.events.find(e=>e.kind==='guild').amount,3);assert.equal(observeInsights(s,b.cursor,now+120).events.length,0);}); +test('report closes at six UTC, records partial baseline and observed totals',()=>{assert.equal(reportWindow(21600),21600);assert.equal(reportWindow(21599),21600-86400);const r=summarize([{kind:'levels',amount:3}],21600,108000,22000,5);assert.equal(r.partial,true);assert.equal(r.observedSince,22000);assert.equal(r.totals.levels,3);assert.equal(r.samples,5);}); +test('activity distinguishes stale data and guarded actions from inferred stalls',()=>{const b=bot();assert.equal(activityReason(b,now).label,'Possible stall');b.world.inCombat=true;assert.equal(activityReason(b,now).label,'In combat');b.world.at=now-121;assert.equal(activityReason(b,now).label,'Telemetry delayed');b.world.at=now;b.world.inCombat=false;b.world.casting=true;assert.equal(activityReason(b,now).label,'Casting');b.world.casting=false;b.lastDecision.action='rest';assert.equal(activityReason(b,now).label,'Intends to rest');}); + +test('pending social events count once after native confirmation',()=>{const s=state();s.community.feed=[{id:'social-1:reply',kind:'conversation',status:'pending',at:new Date(now*1000).toISOString(),names:['A','B']}];const a=observeInsights(s,undefined,now);s.community.feed[0].status='confirmed';const b=observeInsights(s,a.cursor,now+60);assert.equal(b.events.length,1);assert.equal(observeInsights(s,b.cursor,now+120).events.length,0);});