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={intentResults?:string[];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}); cursor.intentResults=[]; 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<=180){ if(b.status==='online'&&old.status==='online'){add(`online-time:${b.id}:${now}`,'onlineSeconds',b.name,elapsed,'Fresh consecutive online observations, including recovery time');if(!w!.isDead&&!w!.isGhost&&(moved||w!.inCombat||w!.casting))add(`active-time:${b.id}:${now}`,'activeSeconds',b.name,elapsed,'Sampled movement, combat or casting; not proof of useful progress');} 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'); } for(const check of b.autonomy?.intentChecks??[]){ if(check.status==='pending')continue; cursor.intentResults!.push(check.id); if(previous&&!(previous.intentResults??[]).includes(check.id)&&(check.checkedAt??0)>=previous.at)add('intent:'+check.id,'intent'+(check.status==='observed'?'Observed':check.status==='interrupted'?'Interrupted':'Unverified'),b.name,1,check.action+': '+check.detail,check.checkedAt); } 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)||atnow+60)continue; if(e.kind==='sharedQuest')add('social:'+e.id,'sharedQuests',e.names.join(' & '),1,e.text,at); if(e.kind==='conversation'&&/^social-[^:]+:reply$/.test(e.id))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={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()}; }