Files
AzerothAI/core/realm-insights.ts
T
2026-09-24 09:24:57 +12:00

76 lines
8.6 KiB
TypeScript

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={partyKey?:string;nearPartyKey?:string;worldAt?:number;deathMemoryId?:number;level:number;status:string;seenOnline:boolean;x?:number;y?:number;map?:number;health?:number;lastProgressAt?:number};
export type Cursor={at:number;startedAt:number;bots:Record<string,Progress>;bonds:string[];guilds:Record<string,{goal:string;progress:number}>;social:string[]};
export function observeInsights(s:State,previous:Cursor|undefined,now:number){
const events:Evidence[]=[];const cursor:Cursor={at:now,startedAt:previous?.startedAt??now,bots:{},bonds:[],guilds:{},social:[]};
const add=(id:string,kind:string,name:string,amount:number,detail:string,at=now)=>events.push({id,at,kind,name,amount,detail});
const managed=s.bots.filter(b=>b.autonomy?.managed),ids=new Set(managed.map(b=>b.id));
for(const b of managed){
const old=previous?.bots[b.id],w=b.world;
const fresh=!!w&&now-w.at<120;
const moved=fresh&&old?.x!==undefined&&(old.map!==w!.mapId||Math.hypot(w!.x-old.x,w!.y-(old.y??w!.y))>=3);
const advanced=!!old&&(b.level>old.level||(fresh&&old.health!==undefined&&w!.healthPct!==undefined&&w!.healthPct!==old.health));
const peers=fresh&&b.status==='online'&&!w!.isDead?(w!.groupNames??[]).filter(n=>n!==b.name&&managed.some(p=>p.name===n&&p.status==='online'&&p.world&&!p.world.isDead&&now-p.world.at<120&&p.world.groupId===w!.groupId)):[];
const partyKey=fresh&&w!.groupId&&peers.length?[b.name,...peers].sort().join('|'):undefined;
const nearPartyKey=partyKey&&peers.some(n=>(w!.nearby??[]).includes(n))?partyKey:undefined;
const elapsed=previous?now-previous.at:0;
if(old&&fresh&&w!.at>(old.worldAt??0)&&elapsed>0&&elapsed<=120){
if(partyKey&&partyKey===old.partyKey)add(`party-time:${b.id}:${now}`,'groupedSeconds',b.name,elapsed,'Sampled seconds in the same living party');
if(nearPartyKey&&nearPartyKey===old.nearPartyKey)add(`party-near:${b.id}:${now}`,'nearPartySeconds',b.name,elapsed,'Sampled seconds with a party member within 35 yards');
}
if(previous&&w?.deathMemoryId&&w.deathMemoryId!==(old?.deathMemoryId??0)&&(w.deathMemoryAt??0)>=previous.at)add(`learned-death:${b.id}:${w.deathMemoryId}`,'learnedDeaths',b.name,1,`Encounter remembered: ${w.deathMemoryKiller??'unknown enemy'}`);
cursor.bots[b.id]={partyKey,nearPartyKey,worldAt:fresh?w!.at:old?.worldAt,deathMemoryId:w?.deathMemoryId??old?.deathMemoryId,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'||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)||at<previous.startedAt||at>now+60)continue;
if(e.kind==='sharedQuest')add('social:'+e.id,'sharedQuests',e.names.join(' & '),1,e.text,at);
if(e.kind==='conversation'&&e.id.startsWith('social-')&&e.id.endsWith(':reply'))add('social:'+e.id,'conversations',e.names.join(' & '),1,'Completed bot-to-bot exchange',at);
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};
}
function activityReasonBase(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.isDead||w.isGhost||(w.healthPct??100)<=0)return {label:w.isGhost?'Recovering as a ghost':'Dead / awaiting recovery',detail:'Normal resurrection is handled by the game. Corpse travel is not confirmed by this snapshot.'};
if(w.survivalAt&&now-w.survivalAt<30&&w.survivalAction){
const elapsed=Math.max(0,now-(w.survivalSince??w.survivalAt));
const label=w.survivalAction==='recovering readiness'?(w.manaPct!==undefined&&w.manaPct<65?'Recovering mana':'Recovering health'):w.survivalAction==='regroup requested'?`Catching up to ${w.leaderName||'party leader'}`:w.survivalAction;
return {label,detail:`${Math.floor(elapsed/60)}m ${Math.floor(elapsed%60)}s in this activity · Health ${Math.round(w.healthPct??0)}%${w.manaPct!==undefined?` · Mana ${Math.round(w.manaPct)}%`:''}. Native action observed; completion is not assumed.`};
}
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.trainingAt&&now-w.trainingAt<90&&w.trainingStatus==='traveling')return {label:'Visiting class trainer',detail:`Walking to ${w.trainingTrainer??'class trainer'} · ${w.trainingPending??0} eligible spells pending. Purchases require the normal trainer interaction and sufficient money.`};
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 activityReason(b:Bot,now:number){
const result=activityReasonBase(b,now),w=b.world;
if(w?.trainingAt&&now-w.trainingAt<120)return {...result,detail:result.detail+` Training check at level ${w.trainingLevel}: ${w.trainingStatus}; ${w.trainingPending??0} identified spells pending, ${w.trainingLearned??0} verified trainer purchases.`};
return result;
}
export function summarize(events:Evidence[],start:number,end:number,startedAt:number,samples:number){
const totals:Record<string,number>={levels:0,deaths:0,arrivals:0,conversations:0,bonds:0,parties:0,recruits:0,guild:0,recoveries:0};
for(const e of events)totals[e.kind]=(totals[e.kind]??0)+e.amount;
return {start,end,observedSince:Math.max(start,startedAt),partial:startedAt>start,samples,totals,highlights:events.filter(e=>['levels','deaths','arrivals','guild','bonds'].includes(e.kind)).slice(-40).reverse()};
}