Ground bot intentions in live game knowledge and verify outcomes
Core tests / core (push) Successful in 28s

This commit is contained in:
Codex
2026-09-25 13:28:03 +08:00
parent 20ccd0fced
commit 502cb1e4fd
17 changed files with 251 additions and 24 deletions
+30 -6
View File
@@ -1,3 +1,5 @@
import {startIntentCheck} from './intent-evidence';
import {intentCompanions,partyIntentBusy,recordPartyIntent,compileCommunityAction} from './community';
import {DeterministicProvider,providerForBot} from './llm';
import {luaString} from './ale';
import {queueAleCommand,recentAleEvents} from './ale-store';
@@ -26,7 +28,7 @@ function latestObservationFor(bot:Bot,events:Awaited<ReturnType<typeof recentAle
});
}
function observationFrom(bot:Bot,event:Awaited<ReturnType<typeof recentAleEvents>>[number],events:Awaited<ReturnType<typeof recentAleEvents>>,hardcore=true):Observation{
export function observationFrom(bot:Bot,event:Awaited<ReturnType<typeof recentAleEvents>>[number],events:Awaited<ReturnType<typeof recentAleEvents>>,hardcore=true):Observation{
const p=payloadObject(event.payload);
const zone=typeof p.zoneName==='string'&&p.zoneName.trim()?p.zoneName.trim().slice(0,120):bot.zone;
const level=typeof p.level==='number'&&Number.isInteger(p.level)&&p.level>0&&p.level<=100?p.level:bot.level;
@@ -38,11 +40,23 @@ function observationFrom(bot:Bot,event:Awaited<ReturnType<typeof recentAleEvents
zone,
level,
alive:bot.status!=='dead',
availableActions:[...bot.personality.activities],
summary:`Realm death rules: ${hardcore?'permanent death':'normal resurrection; recover and resume progression after death'}. Authoritative live Playerbot observation in ${zone} at level ${level}. Recent server events: ${recent.join(', ')||'snapshot'}.`
availableActions:availableIntentActions(bot),
summary:`Realm death rules: ${hardcore?'permanent death':'normal resurrection; recover and resume progression after death'}. Authoritative live Playerbot observation in ${zone} at level ${level}. Recent server events: ${recent.join(', ')||'snapshot'}. ${decisionContext(bot)}`
};
}
export function availableIntentActions(bot:Bot){
const actions=[...bot.personality.activities],w=bot.world;
if(w&&Date.now()/1000-w.at>=0&&Date.now()/1000-w.at<90&&(w.healthPct??0)>=95&&(w.manaPct===undefined||w.manaPct>=65)&&actions.some(a=>a!=='rest'))return actions.filter(a=>a!=='rest');
return actions;
}
export function decisionContext(bot:Bot){
const w=bot.world;if(!w||Date.now()/1000-w.at>90)return 'Live condition details unavailable; do not invent them.';
const ready=w.readinessAt&&Date.now()/1000-w.readinessAt<180;
const last=bot.autonomy?.intentChecks?.filter(c=>c.status!=='pending').slice(-2).map(c=>`${c.action}: ${c.status}; ${c.detail}`).join(' | ');
return JSON.stringify({health:w.healthPct,mana:w.manaPct,dead:w.isDead||w.isGhost,inCombat:w.inCombat,nearbyThreats:w.threatBrief??'unknown',activeQuestLog:w.questBrief??'unknown',supplies:ready?{food:w.foodCount,drinks:w.drinkCount,durability:w.minDurabilityPct,brokenItems:w.brokenItems}:null,party:w.groupNames,nearby:w.nearby,recentOutcomes:last||'No outcome verified yet',capabilities:'quest/explore request native progression; trade requests native supplies/repair; socialize can invite an eligible nearby companion. Never claim an action completed until observed.'});
}
function strategy(ai:string,change:string){
return `${ai}:ChangeStrategy(${luaString(change)},1)`;
}
@@ -55,6 +69,7 @@ export function compileLivePlayerbotIntent(botName:string,decision:Decision){
`if not ${p}:IsPlayerbot() then error("Managed character is not controlled by PlayerbotAI") end`,
`local ${ai}=${p}:GetBotAI()`,
`if not ${ai} then error("PlayerbotAI unavailable") end`,
`if ${p}:IsDead() then return "Native resurrection takes priority" end`,
`if ${ai}:HasGameClientMaster() then return "Human control preserved" end`,
`if ${p}:HasUnitState(0x00000100) then return "Native taxi flight takes priority" end`,
`if AzerothAISurvivalOverride and AzerothAISurvivalOverride(${p}) then return "Native survival action takes priority" end`,
@@ -82,7 +97,7 @@ export function compileLivePlayerbotIntent(botName:string,decision:Decision){
case 'trade':
clearStay();
body.push(strategy(ai,'+rpg'));
body.push(`${ai}:DoSpecificAction("rpg sell",true,"")`);
body.push(`${ai}:DoSpecificAction("rpg sell",true,"");${ai}:DoSpecificAction("rpg buy",true,"");${ai}:DoSpecificAction("repair",true,"")`);
body.push(`${ai}:DoNextAction(false)`);
break;
case 'rest':
@@ -112,7 +127,7 @@ export async function runLiveAutonomyTick(
const events=await recentAleEvents(db,400);
const candidates=s.bots
.filter(b=>b.status!=='dead'&&b.status!=='retired'&&b.autonomy?.managed===true)
.filter(b=>b.status==='online'&&b.autonomy?.managed===true&&!b.world?.isDead&&!b.world?.isGhost&&!b.world?.humanControlled&&!b.world?.taxi)
.map(bot=>({bot,obs:latestObservationFor(bot,events)}))
.filter((x):x is {bot:Bot;obs:NonNullable<typeof x.obs>}=>!!x.obs)
.filter(x=>now-Date.parse(x.obs.at)<=OBSERVATION_MAX_AGE_MS)
@@ -147,6 +162,8 @@ export async function runLiveAutonomyTick(
const observation=observationFrom(bot,pick.obs,events,s.settings.hardcore.enabled);
const guild=s.community?.guilds.find(g=>g.id===bot.world?.guildId);
if(guild)observation.summary+=` Your guild ${guild.name} works toward ${guild.goal}; confirmed progress ${guild.progress}/${guild.target}. Favor helpful actions toward that goal when safe.`;
const companions=intentCompanions(s,bot).slice(0,3);
observation.summary+=' Eligible nearby party candidates: '+(companions.map(p=>`${p.name} (level ${p.level}, ${p.world?.combatRole??p.role})`).join(', ')||'none; do not invent a companion or promise a party')+'.';
const brain=providerForBot(bot,s.settings,config);
let decision:Decision;
let usedFallback=false;
@@ -168,8 +185,14 @@ export async function runLiveAutonomyTick(
}
bot=currentBot;
if(usedFallback)realmEvent(s,'system',`${bot.name} LLM route unavailable; used deterministic fallback for this heartbeat.`,bot.id);
const source=compileLivePlayerbotIntent(bot.name,decision);
// Recheck eligibility after inference; native guards recheck once more at execution.
let peer=decision.action==='socialize'?intentCompanions(s,bot)[0]:undefined;
if(peer&&await partyIntentBusy(db,bot,peer))peer=undefined;
const source=peer?compileCommunityAction('party',bot,peer):compileLivePlayerbotIntent(bot.name,decision);
const queued=await queueAleCommand(db,source);
if(peer)await recordPartyIntent(db,s,queued.id,bot,peer);
const check=startIntentCheck(bot,decision,queued.id,Date.now()/1000,peer?.name);
bot.lastDecision=decision;
bot.decisions++;
@@ -182,6 +205,7 @@ export async function runLiveAutonomyTick(
observedAt:newerObservation?bot.autonomy?.observedAt:pick.obs.at,
lastIntentAt:new Date(now).toISOString(),
nextIntentAt:new Date(now+nextIntervalMs(bot)).toISOString(),
intentChecks:[...(bot.autonomy?.intentChecks??[]),...(check?[check]:[])].slice(-12),
lastAleCommandId:queued.id
};
realmEvent(s,'system',`${bot.name} brain route: ${brain.route.provider} · ${brain.route.model} · autonomous live heartbeat.`,bot.id);
+20 -2
View File
@@ -1,3 +1,4 @@
import {observeIntentChecks} from './intent-evidence';
import type {Bot,State,StateStore,SocialEntry} from './types';
import {luaString} from './ale';
import {queueAleCommand} from './ale-store';
@@ -24,7 +25,8 @@ export function observePresence(s:State,b:Bot,p:Record<string,unknown>){
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={trainingPending:typeof p.trainingPending==='number'?p.trainingPending:undefined,trainingLevel:typeof p.trainingLevel==='number'?p.trainingLevel:undefined,trainingTrainer:typeof p.trainingTrainer==='string'?p.trainingTrainer:undefined,trainingStatus:typeof p.trainingStatus==='string'?p.trainingStatus:undefined,trainingAt:typeof p.trainingAt==='number'?p.trainingAt:undefined,trainingLearned:typeof p.trainingLearned==='number'?p.trainingLearned:undefined,lastTrainingAt:typeof p.lastTrainingAt==='number'?p.lastTrainingAt:undefined,minDurabilityPct:typeof p.minDurabilityPct==='number'?p.minDurabilityPct:undefined,survivalSince:typeof p.survivalSince==='number'?p.survivalSince:undefined,isDead:typeof p.isDead==='boolean'?p.isDead:undefined,isGhost:typeof p.isGhost==='boolean'?p.isGhost:undefined,manaPct:typeof p.manaPct==='number'?p.manaPct:undefined,leaderName:typeof p.leaderName==='string'?p.leaderName:undefined,brokenItems:typeof p.brokenItems==='number'?p.brokenItems:undefined,freeTalents:typeof p.freeTalents==='number'?p.freeTalents:undefined,foodCount:typeof p.foodCount==='number'?p.foodCount:undefined,drinkCount:typeof p.drinkCount==='number'?p.drinkCount:undefined,readinessAt:typeof p.readinessAt==='number'?p.readinessAt:undefined,deathMemoryId:typeof p.deathMemoryId==='number'?p.deathMemoryId:undefined,deathMemoryAt:typeof p.deathMemoryAt==='number'?p.deathMemoryAt:undefined,deathMemoryKiller:typeof p.deathMemoryKiller==='string'?p.deathMemoryKiller:undefined,deathMemoryMap:typeof p.deathMemoryMap==='number'?p.deathMemoryMap:undefined,deathMemoryX:typeof p.deathMemoryX==='number'?p.deathMemoryX:undefined,deathMemoryY:typeof p.deathMemoryY==='number'?p.deathMemoryY:undefined,deathMemoryLevel:typeof p.deathMemoryLevel==='number'?p.deathMemoryLevel:undefined,quests:Array.isArray(p.quests)?p.quests.filter((q):q is number=>typeof q==='number'&&q>0).slice(0,25):undefined,dangerAt:typeof p.dangerAt==='number'?p.dangerAt:old?.dangerAt,survivalAction:typeof p.survivalAction==='string'?p.survivalAction:undefined,survivalAt:typeof p.survivalAt==='number'?p.survivalAt:undefined,combatRole:typeof p.combatRole==='string'?p.combatRole:undefined,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};
b.world={xp:typeof p.xp==='number'&&Number.isFinite(p.xp)?p.xp:undefined,questBrief:typeof p.questBrief==='string'?p.questBrief.slice(0,1800):undefined,threatBrief:typeof p.threatBrief==='string'?p.threatBrief.slice(0,600):undefined,trainingPending:typeof p.trainingPending==='number'?p.trainingPending:undefined,trainingLevel:typeof p.trainingLevel==='number'?p.trainingLevel:undefined,trainingTrainer:typeof p.trainingTrainer==='string'?p.trainingTrainer:undefined,trainingStatus:typeof p.trainingStatus==='string'?p.trainingStatus:undefined,trainingAt:typeof p.trainingAt==='number'?p.trainingAt:undefined,trainingLearned:typeof p.trainingLearned==='number'?p.trainingLearned:undefined,lastTrainingAt:typeof p.lastTrainingAt==='number'?p.lastTrainingAt:undefined,minDurabilityPct:typeof p.minDurabilityPct==='number'?p.minDurabilityPct:undefined,survivalSince:typeof p.survivalSince==='number'?p.survivalSince:undefined,isDead:typeof p.isDead==='boolean'?p.isDead:undefined,isGhost:typeof p.isGhost==='boolean'?p.isGhost:undefined,manaPct:typeof p.manaPct==='number'?p.manaPct:undefined,leaderName:typeof p.leaderName==='string'?p.leaderName:undefined,brokenItems:typeof p.brokenItems==='number'?p.brokenItems:undefined,freeTalents:typeof p.freeTalents==='number'?p.freeTalents:undefined,foodCount:typeof p.foodCount==='number'?p.foodCount:undefined,drinkCount:typeof p.drinkCount==='number'?p.drinkCount:undefined,readinessAt:typeof p.readinessAt==='number'?p.readinessAt:undefined,deathMemoryId:typeof p.deathMemoryId==='number'?p.deathMemoryId:undefined,deathMemoryAt:typeof p.deathMemoryAt==='number'?p.deathMemoryAt:undefined,deathMemoryKiller:typeof p.deathMemoryKiller==='string'?p.deathMemoryKiller:undefined,deathMemoryMap:typeof p.deathMemoryMap==='number'?p.deathMemoryMap:undefined,deathMemoryX:typeof p.deathMemoryX==='number'?p.deathMemoryX:undefined,deathMemoryY:typeof p.deathMemoryY==='number'?p.deathMemoryY:undefined,deathMemoryLevel:typeof p.deathMemoryLevel==='number'?p.deathMemoryLevel:undefined,quests:Array.isArray(p.quests)?p.quests.filter((q):q is number=>typeof q==='number'&&q>0).slice(0,25):undefined,dangerAt:typeof p.dangerAt==='number'?p.dangerAt:old?.dangerAt,survivalAction:typeof p.survivalAction==='string'?p.survivalAction:undefined,survivalAt:typeof p.survivalAt==='number'?p.survivalAt:undefined,combatRole:typeof p.combatRole==='string'?p.combatRole:undefined,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};
observeIntentChecks(b);
if(b.world.deathMemoryId && b.world.deathMemoryId>(b.deathMemorySeenId??0)){
b.deathMemorySeenId=b.world.deathMemoryId;
b.memories.push({id:`native-death:${b.world.deathMemoryId}`,text:`I died to ${b.world.deathMemoryKiller??'an unknown enemy'} at level ${b.world.deathMemoryLevel??b.level}, map ${b.world.deathMemoryMap}, near (${Math.round(b.world.deathMemoryX??0)}, ${Math.round(b.world.deathMemoryY??0)}). Approach similar enemies there cautiously; recover before fighting again.`,importance:10,source:'observation',at:new Date((b.world.deathMemoryAt??at)*1000).toISOString()});
@@ -58,13 +60,29 @@ export function companionScore(a:Bot,b:Bot){
const shared=(a.world?.quests??[]).filter(q=>b.world?.quests?.includes(q)).length;
return Math.min(40,shared*15)+(recentDanger(b)?30:0)+roleBonus+Math.min(20,Math.max(0,affinity(a,b)))+Math.max(0,10-Math.abs(a.level-b.level));
}
const fresh=(b:Bot)=>b.status==='online'&&b.autonomy?.managed&&b.world&&Date.now()/1000-b.world.at<90&&!b.world.inCombat&&!b.world.humanControlled&&!b.world.taxi&&!b.world.casting;
const fresh=(b:Bot)=>b.status==='online'&&b.autonomy?.managed&&b.world&&Date.now()/1000-b.world.at<90&&!b.world.isDead&&!b.world.isGhost&&!b.world.inCombat&&!b.world.humanControlled&&!b.world.taxi&&!b.world.casting;
export function partyDecision(a:Bot,b:Bot){
if(!fresh(a)||!fresh(b)||a.id===b.id||a.world!.team!==b.world!.team||Math.abs(a.level-b.level)>5||!a.world!.nearby.includes(b.name)||!b.world!.nearby.includes(a.name))return {accepted:false,reason:'No compatible nearby pair'};
const danger=recentDanger(a)||recentDanger(b);
const willing=(x:Bot,y:Bot)=>affinity(x,y)>=0&&(danger||x.personality.sociability>=35||affinity(x,y)>=5)&&x.personality.activities.some(v=>v==='quest'||v==='explore');
return {accepted:willing(a,b)&&willing(b,a),reason:willing(a,b)&&willing(b,a)?danger?'Seeking safety together after recent danger; compatible faction, levels, and non-hostile companions.':'Both accepted: compatible levels, shared adventure interests, and no hostile bond.':'One character prefers to adventure alone or does not trust the other.'};
}
export function intentCompanions(s:State,a:Bot){
if(!s.community?.partiesEnabled)return [];
const groupSafe=(p:Bot)=>!p.world?.groupId||(p.world.groupNames.length<5&&p.world.groupNames.length>0&&p.world.groupNames.every(n=>s.bots.some(m=>m.name===n&&fresh(m)&&m.world!.groupId===p.world!.groupId)));
if(!groupSafe(a))return [];
return s.bots.filter(b=>!b.world?.groupId&&partyDecision(a,b).accepted).sort((x,y)=>companionScore(a,y)-companionScore(a,x));
}
export async function partyIntentBusy(db:D1Database,a:Bot,b:Bot){
await ensure(db);
const row=await db.prepare("SELECT id FROM community_actions WHERE (a IN (?,?) OR b IN (?,?)) AND (status='pending' OR created_at>?) LIMIT 1").bind(a.id,b.id,a.id,b.id,new Date(Date.now()-120000).toISOString()).first();
return !!row;
}
export async function recordPartyIntent(db:D1Database,s:State,id:string,a:Bot,b:Bot){
const reason='Seeking a nearby compatible adventuring companion; awaiting native confirmation.';
await db.prepare('INSERT INTO community_actions VALUES (?,?,?,?,?,?,?,?)').bind(id,new Date().toISOString(),'party',a.id,b.id,id,'pending',reason).run();
socialEntry(s,{id:id+':proposal',at:new Date().toISOString(),kind:'invitation',names:[a.name,b.name],text:reason,status:'pending'});
}
export function compileCommunityAction(kind:'party'|'leave'|'recruit',a:Bot,b?:Bot){
if(kind==='party'){
if(!b||!a.autonomy?.accountId||!b.autonomy?.accountId)throw new Error('Verified native accounts required');
+25
View File
@@ -0,0 +1,25 @@
import type {Bot,Decision,IntentCheck} from './types';
export function startIntentCheck(bot:Bot,decision:Decision,commandId:string,now:number,partyPeer?:string):IntentCheck|undefined{
const w=bot.world;if(!w||now-w.at>90||now<w.at)return;
return {id:commandId,action:decision.action,startedAt:now,baselineAt:w.at,status:'pending',detail:'Waiting for a fresh game observation; command submission is not completion.',partyPeer,baseline:{level:bot.level,xp:w.xp,map:w.mapId,x:w.x,y:w.y,hp:w.healthPct,mana:w.manaPct,food:w.foodCount,drink:w.drinkCount,durability:w.minDurabilityPct,readinessAt:w.readinessAt,groupNames:[...w.groupNames]}};
}
export function observeIntentChecks(bot:Bot){
const w=bot.world;if(!w)return;
for(const c of bot.autonomy?.intentChecks??[]){
if(c.status!=='pending'||w.at<=c.baselineAt||w.at<c.startedAt)continue;
const finish=(status:IntentCheck['status'],detail:string)=>{c.status=status;c.detail=detail;c.checkedAt=w.at;};
if(w.isDead||w.isGhost){finish('interrupted','Death interrupted the intention; native resurrection takes priority.');continue;}
if(w.humanControlled){finish('interrupted','Human control took priority.');continue;}
const b=c.baseline,elapsed=w.at-c.startedAt;
if(c.action==='quest'&&(bot.level>b.level||(w.xp!==undefined&&b.xp!==undefined&&w.xp>b.xp))){finish('observed','Experience or level increased after the intention; this alone does not prove a quest turn-in.');continue;}
if(c.action==='explore'&&(w.mapId!==b.map||Math.hypot(w.x-b.x,w.y-b.y)>=20)){finish('observed','Travel of at least 20 yards or a map change observed.');continue;}
if(c.action==='rest'&&!w.inCombat&&(w.healthPct??0)>=95&&(w.manaPct===undefined||w.manaPct>=65)){
finish('observed','Recovered health and sufficient mana observed out of combat.');continue;
}
if(c.action==='trade'&&(w.readinessAt??0)>(b.readinessAt??0)&&((w.foodCount??0)>(b.food??Infinity)||(w.drinkCount??0)>(b.drink??Infinity)||(w.minDurabilityPct??0)>(b.durability??Infinity))){finish('observed','Supplies or equipment condition improved; purchase attribution is not established.');continue;}
if(c.action==='socialize'&&c.partyPeer&&w.groupId&&w.groupNames.includes(c.partyPeer)){
finish('observed','Party membership with '+c.partyPeer+' verified in the game.');continue;
}
if(elapsed>=600)finish('no_progress',c.action==='socialize'&&!c.partyPeer?'No measurable party objective was attached; dialogue is counted separately.':'No matching outcome observed within ten minutes. This is not proof of an engine failure.');
}
}
+2 -2
View File
@@ -11,7 +11,7 @@ export type BrainRoute={
function survivalDecision(bot:Bot,observation:Observation){
const w=bot.world;
if(w&&Date.now()/1000-w.at<90&&typeof w.healthPct==='number'&&w.healthPct<85&&bot.personality.activities.includes('rest')&&observation.availableActions.includes('rest'))
if(w&&Date.now()/1000-w.at>=0&&Date.now()/1000-w.at<90&&typeof w.healthPct==='number'&&(w.healthPct<85||(typeof w.manaPct==='number'&&w.manaPct<35))&&bot.personality.activities.includes('rest')&&observation.availableActions.includes('rest'))
return {action:'rest' as const,goal:'Recover before risking another fight.',reason:'Staying alive comes before my task. I need to recover and reassess the danger.'};
return null;
}
@@ -54,7 +54,7 @@ export class LocalLLMProvider implements LLMProvider {
content:JSON.stringify({
identity:{name:bot.name,race:bot.race,role:bot.role,tier:bot.tier},
home:bot.homeId??null,
safety:{healthPercent:bot.world?.healthPct??null,observedAt:bot.world?.at??null,inCombat:bot.world?.inCombat??null,nearbyCompanions:bot.world?.nearby??[],lastSurvivalAction:bot.world?.survivalAction??null},
safety:bot.world&&Date.now()/1000-bot.world.at<90?{manaPercent:bot.world.manaPct??null,healthPercent:bot.world?.healthPct??null,observedAt:bot.world?.at??null,inCombat:bot.world?.inCombat??null,nearbyCompanions:bot.world?.nearby??[],lastSurvivalAction:bot.world?.survivalAction??null}:null,
personality:bot.personality,
goal:bot.goal,
memories:[...bot.memories].sort((a,b)=>b.importance-a.importance).slice(0,memoryLimit),
+8 -1
View File
@@ -3,10 +3,11 @@ 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 type Cursor={intentResults?:string[];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});
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;
@@ -18,9 +19,15 @@ export function observeInsights(s:State,previous:Cursor|undefined,now:number){
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(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){
+3 -3
View File
@@ -1,12 +1,12 @@
import type {State} from './types';
import {activityReason,type Evidence} from './realm-insights';
export const counterKeys=['quests','abandoned','lootEvents','lootItems','partyKills','rewarded','lootedCopper'] as const;
export type NativeOutcome={guid:number;name:string}&Record<typeof counterKeys[number],number>;
export const counterKeys=['quests','abandoned','lootEvents','lootItems','partyKills','rewarded','lootedCopper','nativeDeaths'] as const;
export type NativeOutcome={guid:number;name:string}&Record<Exclude<typeof counterKeys[number],'nativeDeaths'>,number>&{nativeDeaths?:number};
export type OutcomeBaseline={at:number;startedAt:number;rows:NativeOutcome[]};
export function outcomeChanges(rows:NativeOutcome[],previous:OutcomeBaseline|undefined,now:number){
const events:Evidence[]=[];
for(const r of rows){const old=previous?.rows.find(b=>b.guid===r.guid);if(!old)continue;
for(const key of counterKeys){const amount=r[key]-old[key];if(amount>0)events.push({id:`outcome:${r.guid}:${key}:${now}:${r[key]}`,at:now,kind:key,name:r.name,amount,detail:({lootedCopper:'Currency looted',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]});}
for(const key of counterKeys){const value=r[key],prior=old[key];if(typeof value!=='number'||typeof prior!=='number')continue;const amount=value-prior;if(amount>0)events.push({id:`outcome:${r.guid}:${key}:${now}:${r[key]}`,at:now,kind:key,name:r.name,amount,detail:({nativeDeaths:'Native death records, including normal resurrection',lootedCopper:'Currency looted',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};
}
+3 -2
View File
@@ -9,8 +9,9 @@ export type Relationship = {targetId:string; affinity:number; note:string};
export type DeathRecord = {id:string; at:string; zone:string; level:number; cause:string; killer?:string; source:'operator'|'simulation'|'azerothcore'; verified:boolean};
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 = {trainingPending?:number;trainingLevel?:number;trainingTrainer?:string;trainingStatus?:string;trainingAt?:number;trainingLearned?:number;lastTrainingAt?:number;survivalSince?:number;isDead?:boolean;isGhost?:boolean;manaPct?:number;leaderName?:string;minDurabilityPct?:number;brokenItems?:number;freeTalents?:number;foodCount?:number;drinkCount?:number;readinessAt?:number;deathMemoryId?:number;deathMemoryAt?:number;deathMemoryKiller?:string;deathMemoryMap?:number;deathMemoryX?:number;deathMemoryY?:number;deathMemoryLevel?:number;quests?:number[];dangerAt?:number;survivalAction?:string;survivalAt?:number;combatRole?:string;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 IntentCheck = {id:string;action:Activity;startedAt:number;baselineAt:number;checkedAt?:number;status:"pending"|"observed"|"no_progress"|"interrupted";detail:string;partyPeer?:string;baseline:{level:number;xp?:number;map:number;x:number;y:number;hp?:number;mana?:number;food?:number;drink?:number;durability?:number;readinessAt?:number;groupNames:string[]}};
export type AutonomyState = {intentChecks?:IntentCheck[];managed:boolean; characterGuid?:number; accountId?:number; observedAt?:string; lastIntentAt?:string; nextIntentAt?:string; lastAleCommandId?:string};
export type WorldPresence = {xp?:number;questBrief?:string;threatBrief?:string;trainingPending?:number;trainingLevel?:number;trainingTrainer?:string;trainingStatus?:string;trainingAt?:number;trainingLearned?:number;lastTrainingAt?:number;survivalSince?:number;isDead?:boolean;isGhost?:boolean;manaPct?:number;leaderName?:string;minDurabilityPct?:number;brokenItems?:number;freeTalents?:number;foodCount?:number;drinkCount?:number;readinessAt?:number;deathMemoryId?:number;deathMemoryAt?:number;deathMemoryKiller?:string;deathMemoryMap?:number;deathMemoryX?:number;deathMemoryY?:number;deathMemoryLevel?:number;quests?:number[];dangerAt?:number;survivalAction?:string;survivalAt?:number;combatRole?:string;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};