diff --git a/ale/azerothai_bridge.lua b/ale/azerothai_bridge.lua index 08954ec..abbf7aa 100644 --- a/ale/azerothai_bridge.lua +++ b/ale/azerothai_bridge.lua @@ -147,12 +147,19 @@ local function world_payload(player) p.humanControlled=ai and ai:HasGameClientMaster() or false p.quests={};if ai then for slot=0,24 do local q=player:GetQuestSlotQuestId(slot);if q and q>0 then p.quests[#p.quests+1]=q end end end if ai then p.combatRole=player:IsHealer() and 'healer' or player:IsTank() and 'tank' or 'damage' end - if AzerothAISurvivalState then p.dangerAt,p.survivalAction,p.survivalAt=AzerothAISurvivalState(player) end + if AzerothAISurvivalState then p.dangerAt,p.survivalAction,p.survivalAt,p.survivalSince=AzerothAISurvivalState(player) end local g=player:GetGroup() p.groupId=g and tostring(g:GetGUID()) or "" local members={} if g then for _,m in pairs(g:GetMembers()) do members[#members+1]=m:GetName() end end p.groupNames=table.concat(members,",") + p.isDead=player:IsDead();p.isGhost=player:HasAura(8326) + p.manaPct=player:GetPowerType()==0 and player:GetPowerPct(0) or nil + if p.manaPct and (p.manaPct~=p.manaPct or p.manaPct==math.huge) then p.manaPct=nil end + local leader=ai and ai:GetGroupLeader() + p.leaderName=leader and leader:GetName() or '' + if AzerothAIExperienceSnapshot then for k,v in pairs(AzerothAIExperienceSnapshot(player)) do p[k]=v end end + if AzerothAIReadinessSnapshot then for k,v in pairs(AzerothAIReadinessSnapshot(player)) do p[k]=v end end p.nearbyBots=nearby_bot_names(player) return p end diff --git a/ale/azerothai_experience.lua b/ale/azerothai_experience.lua new file mode 100644 index 0000000..70b7658 --- /dev/null +++ b/ale/azerothai_experience.lua @@ -0,0 +1,48 @@ +-- Bounded durable encounter memory. No changes to health, resurrection or stats. +local cache={} +local function quote(s)return "UNHEX('"..string.gsub(tostring(s),'.',function(c)return string.format('%02X',string.byte(c))end).."')" end +-- Synchronous DDL: the next snapshot may query immediately after ALE reload. +AuthDBQuery([[CREATE TABLE IF NOT EXISTS azerothai_death_experience ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,guid INT UNSIGNED,account_id INT UNSIGNED, + name VARCHAR(64),at BIGINT UNSIGNED,killer VARCHAR(100),entry INT UNSIGNED, + map INT UNSIGNED,x FLOAT,y FLOAT,level INT UNSIGNED,INDEX resident(guid,at))]]) +local function identity(p) + if not p or not p:IsPlayerbot() or not p:GetBotAI() or p:GetBotAI():HasGameClientMaster() then return false end + local q=AuthDBQuery("SELECT name,account_id FROM azerothai_lifecycle WHERE state='active' AND guid="..p:GetGUIDLow()) + return q and q:GetString(0)==p:GetName() and q:GetUInt32(1)==p:GetAccountId() +end +local function memories(p) + local guid,now=p:GetGUIDLow(),os.time();local c=cache[guid] + if c and c.account==p:GetAccountId() and c.name==p:GetName() and now-c.loaded<60 then return c.rows end + local rows={} + local q=AuthDBQuery('SELECT id,at,killer,entry,map,x,y,level FROM azerothai_death_experience WHERE guid='..guid..' AND account_id='..p:GetAccountId()..' AND name='..quote(p:GetName())..' AND at>'..(now-30*86400)..' ORDER BY id DESC LIMIT 32') + if q then repeat rows[#rows+1]={id=q:GetUInt32(0),at=q:GetUInt32(1),killer=q:GetString(2),entry=q:GetUInt32(3),map=q:GetUInt32(4),x=q:GetFloat(5),y=q:GetFloat(6),level=q:GetUInt32(7)} until not q:NextRow() end + cache[guid]={loaded=now,account=p:GetAccountId(),name=p:GetName(),rows=rows};return rows +end +function AzerothAILearnedDanger(p,target) + local creature=target and target:ToCreature();if not creature then return false end + for _,m in ipairs(memories(p)) do + -- Reassess after three levels or three days. Avoid this creature type near the fatal encounter. + if m.entry==creature:GetEntry() and m.map==p:GetMapId() and os.time()-m.at<3*86400 and p:GetLevel()600 then cache[guid]=nil end end +end,3600000,0) diff --git a/ale/azerothai_readiness.lua b/ale/azerothai_readiness.lua new file mode 100644 index 0000000..7fdb123 --- /dev/null +++ b/ale/azerothai_readiness.lua @@ -0,0 +1,69 @@ +-- Inspect actual inventory and equipment once per minute; normal NPC interactions only. +local snapshots,registry={},{} +-- WotLK 3.3.5 UpdateFields.h: OBJECT_END (6) + 0x36 / 0x37. +-- Read only; verified against this AzerothCore build's item update fields. +local ITEM_DURABILITY,ITEM_MAX_DURABILITY=0x003C,0x003D +local registryAt=0 +local consumables={} +-- Match playerbots' own food/drink categories, without an incomplete item-ID list. +local q=WorldDBQuery('SELECT entry,spellcategory_1 FROM item_template WHERE class=0 AND subclass IN (0,5) AND spellcategory_1 IN (11,59)') +if q then repeat consumables[q:GetUInt32(0)]=q:GetUInt32(1) until not q:NextRow() end +local function managed(p) + local r=p and registry[p:GetGUIDLow()] + return r and p:IsPlayerbot() and r.name==p:GetName() and r.account==p:GetAccountId() and p:GetBotAI() and not p:GetBotAI():HasGameClientMaster() +end +local function safe(p) + if not managed(p) or p:IsDead() or p:IsInCombat() or p:IsCasting() or p:IsTaxi() then return false end + local g=p:GetGroup() + if g then + if g:IsRaidGroup() or #g:GetMembers()~=g:GetMembersCount() then return false end + for _,m in pairs(g:GetMembers()) do if not managed(m) then return false end end + end + return not (AzerothAISurvivalOverride and AzerothAISurvivalOverride(p)) +end +function AzerothAIReadinessSnapshot(p) + local r=snapshots[p:GetGUIDLow()] + return r and r.name==p:GetName() and r.account==p:GetAccountId() and r.values or {} +end +local function inspect(p) + local ai=p:GetBotAI();local v={readinessAt=os.time(),brokenItems=0,freeTalents=p:GetFreeTalentPoints(),foodCount=0,drinkCount=0} + for slot=0,18 do + local item=p:GetItemByPos(255,slot) + if item then + if item:IsBroken() then v.brokenItems=v.brokenItems+1 end + local maximum=item:GetUInt32Value(ITEM_MAX_DURABILITY) + if maximum>0 then local pct=100*item:GetUInt32Value(ITEM_DURABILITY)/maximum;v.minDurabilityPct=math.min(v.minDurabilityPct or 100,pct) end + end + end + for _,item in pairs(ai:GetInventoryItems()) do + local category=consumables[item:GetEntry()] + if category and p:CanUseItem(item) then + if category==11 then v.foodCount=v.foodCount+item:GetCount() else v.drinkCount=v.drinkCount+item:GetCount() end + end + end + snapshots[p:GetGUIDLow()]={name=p:GetName(),account=p:GetAccountId(),values=v} + if not safe(p) then return end + if v.freeTalents>0 then ai:DoSpecificAction('auto talents',true) end + local supplies=v.foodCount<5 or p:GetPowerType()==0 and v.drinkCount<5 + if (v.minDurabilityPct or 100)<30 or v.brokenItems>0 or supplies then + -- RPG handles finding an appropriate vendor and walking there. No spawned supplies. + ai:ChangeStrategy('+rpg',1) + if not ai:HasCheat(2) then + if (v.minDurabilityPct or 100)<30 or v.brokenItems>0 then ai:DoSpecificAction('repair',true) end + if supplies then ai:DoSpecificAction('rpg buy',true) end + end + end +end +CreateLuaEvent(function() + local now=os.time() + if now-registryAt>=60 then + local rows={};local result=AuthDBQuery("SELECT guid,name,account_id FROM azerothai_lifecycle WHERE state='active'") + if result then repeat rows[result:GetUInt32(0)]={name=result:GetString(1),account=result:GetUInt32(2)} until not result:NextRow() end + registry=rows;registryAt=now + end + for _,p in pairs(GetPlayersInWorld() or {}) do if managed(p) then + local old=snapshots[p:GetGUIDLow()] + if not old or now-old.values.readinessAt>=60 then local ok,err=pcall(inspect,p);if not ok then PrintError('[AzerothAI readiness] '..tostring(err)) end end + end end + for guid,r in pairs(snapshots) do if now-r.values.readinessAt>600 then snapshots[guid]=nil end end +end,10000,0) diff --git a/ale/azerothai_survival.lua b/ale/azerothai_survival.lua index 16cd512..ad78c11 100644 --- a/ale/azerothai_survival.lua +++ b/ale/azerothai_survival.lua @@ -55,7 +55,7 @@ local function dangerousSelection(p,r,now) local target=p:GetSelection() if not target then return false end local key=tostring(target:GetGUID()) - if not dangerous(p,target) and not (r.avoidTargets and (r.avoidTargets[key] or 0)>now) then return false end + if not dangerous(p,target) and not (AzerothAILearnedDanger and AzerothAILearnedDanger(p,target)) and not (r.avoidTargets and (r.avoidTargets[key] or 0)>now) then return false end for _,enemy in pairs(p:GetUnfriendlyUnitsInRange(40)) do if enemy:GetGUID()==target:GetGUID() then return true end end @@ -91,7 +91,7 @@ end function AzerothAISurvivalState(p) local r=observed[p:GetGUIDLow()] if not r then return 0,'',0 end - return r.dangerAt or 0,r.action or '',r.actionAt or 0 + return r.dangerAt or 0,r.action or '',r.actionAt or 0,r.actionSince or r.actionAt or 0 end -- Prevent slower model intentions from undoing an immediate safety action. function AzerothAISurvivalOverride(p) @@ -105,6 +105,7 @@ local function sample() if previous then outcome(p,previous,now) end if managed(p) then local id=p:GetGUIDLow();local ai=p:GetBotAI();local r=observed[id] or {};observed[id]=r + local previousAction,previousAt=r.action,r.actionAt or 0 local hp=p:GetHealthPct() local falling=r.hp and r.hp-hp>=20 local mana=p:GetPowerType()==0 and p:GetPowerPct(0) or 100 @@ -125,7 +126,7 @@ local function sample() for _,m in pairs(peers) do if m~=p and m:GetMapId()==p:GetMapId() and m:GetPhaseMask()==p:GetPhaseMask() and p:GetDistance(m)<=35 and m:GetHealthPct()>=70 then support=support+1 end end local attackers=0;for _ in pairs(p:GetAttackers()) do attackers=attackers+1 end local enemy=p:GetVictim() - local overmatched=dangerous(p,enemy) + local overmatched=dangerous(p,enemy) or (AzerothAILearnedDanger and AzerothAILearnedDanger(p,enemy)) r.avoidTargets=r.avoidTargets or {} for key,expires in pairs(r.avoidTargets) do if expires<=now then r.avoidTargets[key]=nil end end if enemy and (overmatched or attackers>support+1 or falling and hp<80) then r.avoidTargets[tostring(enemy:GetGUID())]=now+600 end @@ -195,6 +196,7 @@ local function sample() end end end + if r.actionAt and (r.action~=previousAction or now-previousAt>30) then r.actionSince=now end end end for id,r in pairs(observed) do if now-(r.seen or 0)>600 then observed[id]=nil;ownedMasters[id]=nil end end diff --git a/app/api/realm-insights/route.ts b/app/api/realm-insights/route.ts index 5da087d..820c2b6 100644 --- a/app/api/realm-insights/route.ts +++ b/app/api/realm-insights/route.ts @@ -36,7 +36,7 @@ export async function GET(){ 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 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,'groupedSeconds','nearPartySeconds','sharedQuests'].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'}}); diff --git a/app/realm-diagnostics.tsx b/app/realm-diagnostics.tsx index da29808..90bf823 100644 --- a/app/realm-diagnostics.tsx +++ b/app/realm-diagnostics.tsx @@ -22,7 +22,7 @@ export function RealmDiagnostics({page,onNavigate,initialQuery=''}:{page:string; 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}. Currency tracking starts with this update; historical coin loot is unavailable. 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
{formatCopper(b.totals.lootedCopper??0)}Currency 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.kind==='lootedCopper'?formatCopper(e.amount):e.amount}

)}
} + {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}. Currency tracking starts with this update; historical coin loot is unavailable. 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
{formatCopper(b.totals.lootedCopper??0)}Currency looted
{b.totals.partyKills}Shared kills
Evidence details

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

Time grouped: {Math.floor((b.totals.groupedSeconds??0)/60)} min · Near companions: {Math.floor((b.totals.nearPartySeconds??0)/60)} min. Sampled time; gaps over two minutes are excluded.

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.kind==='lootedCopper'?formatCopper(e.amount):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/core/community.ts b/core/community.ts index 8d4ba57..371ac99 100644 --- a/core/community.ts +++ b/core/community.ts @@ -24,7 +24,12 @@ 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={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={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}; + 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()}); + b.memories=b.memories.slice(-100); + } 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){ @@ -41,6 +46,7 @@ export function applyCooperation(s:State,p:Record){ const kind=p.cooperation; if(kind!=='quest'&&kind!=='assist')return; if(bondEvidence(s,id,a,b,kind==='quest'?'Completed the same quest while adventuring together.':'Helped defeat an enemy together.',kind==='quest'?3:1)){ + if(kind==='quest')socialEntry(s,{id:id+':quest',at:new Date(at*1000).toISOString(),kind:'sharedQuest',names:[a.name,b.name],text:`Completed quest ${String(p.questId)} together.`,status:'confirmed'}); for(const bot of [a,b]){bot.memories.push({id,text:kind==='quest'?`Completed quest ${String(p.questId)} with ${bot.id===a.id?b.name:a.name}.`:`Fought alongside ${bot.id===a.id?b.name:a.name}.`,importance:6,source:'observation',at:new Date().toISOString()});bot.memories=bot.memories.slice(-100);} if(a.world?.guildId&&a.world.guildId===b.world?.guildId){const g=community(s).guilds.find(g=>g.id===a.world!.guildId);if(g&&((g.goal==='quest'&&kind==='quest')||(g.goal==='support'&&kind==='assist')))g.progress++;} } diff --git a/core/realm-insights.ts b/core/realm-insights.ts index 0478d3f..a3ed3b1 100644 --- a/core/realm-insights.ts +++ b/core/realm-insights.ts @@ -2,7 +2,7 @@ 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 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;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:[]}; @@ -13,7 +13,16 @@ export function observeInsights(s:State,previous:Cursor|undefined,now:number){ 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}; + 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}`); @@ -27,6 +36,7 @@ export function observeInsights(s:State,previous:Cursor|undefined,now:number){ 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'&&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); } @@ -36,7 +46,12 @@ 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.survivalAt&&now-w.survivalAt<30&&w.survivalAction)return {label:w.survivalAction,detail:'Native survival assistance requested this action. Completion remains determined by game 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.taxi)return {label:'Traveling by taxi',detail:'The game reports taxi travel. Recovery is skipped.'}; diff --git a/core/realm-outcomes.ts b/core/realm-outcomes.ts index 406ea0a..a1dbf67 100644 --- a/core/realm-outcomes.ts +++ b/core/realm-outcomes.ts @@ -30,7 +30,11 @@ export function attentionItems(s:State,now:number,collectedAt:number|null,outcom 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);} + for(const b of online){const w=b.world;if(w&&!w.isDead&&now-w.at<120&&w.readinessAt&&now-w.readinessAt<180){ + const needs=[(w.minDurabilityPct??100)<30?`lowest equipped durability ${Math.round(w.minDurabilityPct!)}%`:null,(w.brokenItems??0)>0?`${w.brokenItems} broken equipped items`:null,(w.freeTalents??0)>0?`${w.freeTalents} unspent talent points`:null,w.foodCount===0?'no usable food':null,w.manaPct!==undefined&&w.drinkCount===0?'no usable drinks':null].filter(Boolean); + if(needs.length)add('readiness:'+b.id,'warning',b.name+' · supplies and readiness',needs.join(' · ')+'. Native maintenance will attempt normal remedies; completion is checked on the next audit.','Bot Activity',b.name); + } + 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/core/types.ts b/core/types.ts index ef438bc..445c48f 100644 --- a/core/types.ts +++ b/core/types.ts @@ -10,11 +10,11 @@ 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 = {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 WorldPresence = {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}; -export type Bot = {world?:WorldPresence;id:string; name:string; role:string; race:string; level:number; zone:string; status:Status; tier:AgentTier; mortal:boolean; plotArmorHpFloor:number; simulationBand:SimulationBand; homeId?:string; routine:Routine; autonomy?:AutonomyState; recipeId:string; recipeVersion:number; personality:Recipe; goal:string; memories:Memory[]; relationships:Relationship[]; decisions:number; death?:DeathRecord; lastDecision?:Decision}; +export type Bot = {deathMemorySeenId?:number;world?:WorldPresence;id:string; name:string; role:string; race:string; level:number; zone:string; status:Status; tier:AgentTier; mortal:boolean; plotArmorHpFloor:number; simulationBand:SimulationBand; homeId?:string; routine:Routine; autonomy?:AutonomyState; recipeId:string; recipeVersion:number; personality:Recipe; goal:string; memories:Memory[]; relationships:Relationship[]; decisions:number; death?:DeathRecord; lastDecision?:Decision}; export type Decision = {action:Activity; reason:string; goal:string}; export type RealmEvent = {id:string; seq:number; at:string; kind:'system'|'lifecycle'|'decision'|'memory'|'relationship'|'recipe'|'gm'|'death'|'housing'|'population'|'error'; message:string; botId?:string; mode:'simulation'|'azerothcore'}; export type HardcoreSettings = {enabled:boolean; requireVerifiedDeath:boolean; memorialize:boolean}; diff --git a/deploy/playable/install-lifecycle.py b/deploy/playable/install-lifecycle.py index a38a8e2..555ded7 100644 --- a/deploy/playable/install-lifecycle.py +++ b/deploy/playable/install-lifecycle.py @@ -116,7 +116,8 @@ for key in ['POLL_MS', 'PROTECTED', 'KNOWN_BOT_ACCOUNTS']: source, n = re.subn(pattern, lambda match: line.group(0), source, flags=re.M) assert n == 1, key (core / 'azerothai-lua/azerothai_bridge.lua').write_text(source) -shutil.copy2(repo / 'ale/azerothai_lifecycle.lua', core / 'azerothai-lua/azerothai_lifecycle.lua') +for name in ['azerothai_lifecycle.lua', 'azerothai_survival.lua', 'azerothai_experience.lua', 'azerothai_readiness.lua']: + shutil.copy2(repo / 'ale' / name, core / 'azerothai-lua' / name) settings = {'AC_AI_PLAYERBOT_DISABLE_RANDOM_LEVELS': '1', 'AC_AI_PLAYERBOT_RANDOMBOT_STARTING_LEVEL': '1', 'AC_AI_PLAYERBOT_DISABLE_DEATH_KNIGHT_LOGIN': '0'} diff --git a/docs/resident-experience.md b/docs/resident-experience.md new file mode 100644 index 0000000..cdf9e13 --- /dev/null +++ b/docs/resident-experience.md @@ -0,0 +1,13 @@ +# Resident experience and readiness + +Death encounters are saved in `acore_auth.azerothai_death_experience` with verified resident identity, killer, creature entry, level, map and coordinates. The native survival guard avoids the same creature type within 100 yards of a fatal encounter for up to three days, reassessing after three gained levels. The latest encounter is also delivered to the resident's normal AI memories. No death is manufactured to test this behavior. Normal resurrection remains enabled. + +The table retains 30 days of encounters; the guard loads at most 32 per resident and caches reads for one minute. AI memories keep their existing 100-entry bound. Replaying a snapshot does not append the same death memory again. Historical encounters before this feature are not backfilled. + +Every minute, readiness audits count usable food/drinks, inspect equipped durability and check free talent points. Below 30% durability, the bot attempts normal repair; below five food or drinks, it enables native RPG vendor behavior and attempts a purchase at an appropriate target. Talent spending uses the configured native auto-talents action. Purchases/repairs are not forced under the gold cheat. Combat, casting, taxis, human control, mixed/incomplete groups and active survival actions take priority. Failed or unavailable remedies remain visible in Needs attention. Audits cannot guarantee a reachable vendor, enough money, or immediate successful repair. The next audit verifies actual inventory/equipment state. + +Gameplay Results now shows sampled time near companions and confirmed shared quest completions, alongside existing shared kills and quest rewards. Time is accumulated per resident only between consecutive fresh observations in the same living party. Gaps over two minutes and repeated snapshots are excluded. Thus two residents spending one minute together contribute two resident-minutes, not one party-minute. Shared quest completions count confirmed pairs, not unique quests. + +Bot Activity distinguishes health recovery, mana recovery, catching up to a named leader, and ghost/dead states. Native survival activity includes elapsed time. Ghost state alone is not presented as proof of successful corpse travel. The public visitor monitor receives the same read-only diagnostic facts. + +Deploy the updated bridge, survival, experience and readiness Lua modules together, reload ALE, and deploy the dashboard build. New encounter-table DDL is synchronous so the first snapshot cannot race table creation. The lifecycle installer includes these modules for future installations. WotLK item durability reads use verified 3.3.5 update-field offsets; recheck these if changing client/core generation. diff --git a/scripts/spectator-server.py b/scripts/spectator-server.py index 6f5aabb..99c1d3e 100644 --- a/scripts/spectator-server.py +++ b/scripts/spectator-server.py @@ -83,7 +83,7 @@ def public_state(state): bot['routine'] = {**pick(b.get('routine', {}), 'wakeHour sleepHour'), 'preferredZones': b.get('routine', {}).get('preferredZones', [])} bot['autonomy'] = {'managed': True, **pick(b.get('autonomy', {}), 'observedAt lastIntentAt nextIntentAt')} world = b.get('world') or {} - bot['world'] = {**pick(world, 'at team guildId guildName guildRank groupId inCombat x y mapId stationarySince'), + bot['world'] = {**pick(world, 'at team guildId guildName guildRank groupId inCombat x y mapId stationarySince survivalSince survivalAt survivalAction isDead isGhost manaPct healthPct leaderName minDurabilityPct brokenItems freeTalents foodCount drinkCount readinessAt'), 'groupNames': [n for n in world.get('groupNames', []) if any(x['name']==n for x in state['bots'] if x.get('autonomy',{}).get('managed'))], 'nearby': []} bot['relationships'] = [pick(r, 'targetId affinity note') for r in b.get('relationships', [])] # Show shared history, never personal whispers or operator-injected memories. diff --git a/tests/experience-lua.test.py b/tests/experience-lua.test.py new file mode 100644 index 0000000..554687d --- /dev/null +++ b/tests/experience-lua.test.py @@ -0,0 +1,29 @@ +from pathlib import Path +from lupa import LuaRuntime +source=(Path(__file__).resolve().parents[1]/'ale/azerothai_experience.lua').read_text() +l=LuaRuntime();l.execute(''' +now=100000;os.time=function()return now end;events={};writes={};ddl=false +function CreateLuaEvent()end +function RegisterPlayerEvent(n,f)events[n]=f end +function AuthDBExecute(s)table.insert(writes,s)end +function AuthDBQuery(s) + if s:find('CREATE TABLE',1,true) then ddl=true;return nil end + if s:find('azerothai_lifecycle',1,true) then return {GetString=function()return 'A' end,GetUInt32=function()return 12 end} end + assert(ddl) + return {GetUInt32=function(_,i)return ({[0]=7,[1]=99990,[3]=50,[4]=0,[7]=5})[i] end,GetString=function()return 'Wolf' end,GetFloat=function()return 10 end,NextRow=function()return false end} +end +p={level=5,account=12};function p:GetGUIDLow()return 1 end;function p:GetAccountId()return self.account end;function p:GetName()return 'A' end +function p:IsPlayerbot()return true end;function p:GetBotAI()return {HasGameClientMaster=function()return p.controlled end} end +function p:GetMapId()return 0 end;function p:GetLevel()return self.level end;function p:GetX()return 10 end;function p:GetY()return 10 end +enemy={x=10};function enemy:ToCreature()return self end;function enemy:GetEntry()return 50 end;function enemy:GetX()return self.x end;function enemy:GetY()return 10 end;function enemy:GetName()return 'Wolf' end +''');l.execute(source) +l.execute(''' +assert(AzerothAILearnedDanger(p,enemy));enemy.x=500;assert(not AzerothAILearnedDanger(p,enemy));enemy.x=10 +p.level=8;assert(not AzerothAILearnedDanger(p,enemy));p.level=5 +p.account=99;events[8](8,enemy,p);assert(#writes==0);p.account=12 +p.controlled=true;events[8](8,enemy,p);assert(#writes==0);p.controlled=false +events[8](8,enemy,p);assert(#writes==1);events[8](8,enemy,p);assert(#writes==1) +''') +# Reload the whole module: durable SQL memory must still affect target selection. +l.execute(source);l.execute("assert(AzerothAILearnedDanger(p,enemy));now=400000;assert(not AzerothAILearnedDanger(p,enemy))") +print('Death memory: identity, human control, duplicate event, radius, level reassessment, expiry and reload persistence passed') diff --git a/tests/readiness-lua.test.py b/tests/readiness-lua.test.py new file mode 100644 index 0000000..8e842e3 --- /dev/null +++ b/tests/readiness-lua.test.py @@ -0,0 +1,27 @@ +from pathlib import Path +from lupa import LuaRuntime +source=(Path(__file__).resolve().parents[1]/'ale/azerothai_readiness.lua').read_text() +setup=''' +now=1000;os.time=function()return now end;calls={} +function WorldDBQuery()return {GetUInt32=function(_,i)return i==0 and 10 or 11 end,NextRow=function()return false end}end +function AuthDBQuery()return {GetUInt32=function(_,i)return i==0 and 1 or 12 end,GetString=function()return 'A' end,NextRow=function()return false end}end +function CreateLuaEvent(fn)tick=fn end;function PrintError(s)error(s)end +p={ai={}};function GetPlayersInWorld()return {p}end +function p:GetGUIDLow()return 1 end;function p:GetAccountId()return 12 end;function p:GetName()return 'A' end +function p:IsPlayerbot()return true end;function p:GetBotAI()return self.ai end;function p.ai:HasGameClientMaster()return p.human end +function p:IsDead()return p.dead end;function p:IsInCombat()return p.combat end;function p:IsCasting()return false end;function p:IsTaxi()return false end +function p:GetGroup()return p.group end;function p:GetFreeTalentPoints()return 1 end +function p:GetItemByPos(_,slot)if slot==15 then return {IsBroken=function()return true end,GetUInt32Value=function(_,i)return i==0x003D and 50 or 0 end}end end +function p.ai:GetInventoryItems()return {{GetEntry=function()return 10 end,GetCount=function()return 3 end}}end +function p:CanUseItem()return true end;function p:GetPowerType()return 0 end +function p.ai:DoSpecificAction(s)table.insert(calls,s)end;function p.ai:ChangeStrategy(s)table.insert(calls,s)end;function p.ai:HasCheat()return p.goldCheat end +function AzerothAISurvivalOverride()return p.recovering end +''' +def new(): + l=LuaRuntime();l.execute(setup);l.execute(source);return l +l=new();l.execute("tick();local r=AzerothAIReadinessSnapshot(p);assert(r.foodCount==3 and r.drinkCount==0 and r.brokenItems==1 and r.freeTalents==1);assert(#calls==4);tick();assert(#calls==4)") +for condition in ['human','combat','dead','recovering']: + l=new();l.execute(f'p.{condition}=true;tick();assert(#calls==0)') +l=new();l.execute("p.group={IsRaidGroup=function()return false end,GetMembersCount=function()return 2 end,GetMembers=function()return {p} end};tick();assert(#calls==0)") +l=new();l.execute("p.goldCheat=true;tick();assert(#calls==2,'No repair or purchase under gold cheat')") +print('Readiness: inventory counts, broken gear, cooldown, combat/death/recovery/human/group guards and normal-cost actions passed') diff --git a/tests/realm-insights.test.cjs b/tests/realm-insights.test.cjs index 1fab2fd..8e6c59a 100644 --- a/tests/realm-insights.test.cjs +++ b/tests/realm-insights.test.cjs @@ -10,3 +10,21 @@ test('activity distinguishes stale data and guarded actions from inferred stalls 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']);}); + +test('party time requires fresh consecutive matching living-party observations',()=>{ + const s=state();for(const b of s.bots)Object.assign(b.world,{groupId:'g',groupNames:['A','B'],nearby:['A','B']}); + let a=observeInsights(s,undefined,now);assert.equal(a.events.length,0); + for(const b of s.bots)b.world.at=now+60; + let c=observeInsights(s,a.cursor,now+60);assert.equal(c.events.filter(e=>e.kind==='nearPartySeconds').reduce((n,e)=>n+e.amount,0),120); + assert.equal(observeInsights(s,c.cursor,now+90).events.length,0,'same snapshot is not more evidence'); + for(const b of s.bots)b.world.at=now+500; + assert.equal(observeInsights(s,c.cursor,now+500).events.length,0,'long collection gap excluded'); + s.bots[1].world.isDead=true;s.bots[0].world.at=now+90; + assert.equal(observeInsights(s,c.cursor,now+90).events.filter(e=>e.kind==='nearPartySeconds').length,0); +}); +test('monitor distinguishes mana recovery, named leader and ghost state',()=>{ + const b=bot();Object.assign(b.world,{manaPct:20,survivalAction:'recovering readiness',survivalAt:now,survivalSince:now-70}); + assert.equal(activityReason(b,now).label,'Recovering mana');assert.match(activityReason(b,now).detail,/1m 10s/); + b.world.survivalAction='regroup requested';b.world.leaderName='Zaku';assert.equal(activityReason(b,now).label,'Catching up to Zaku'); + b.world.isGhost=true;assert.equal(activityReason(b,now).label,'Recovering as a ghost'); +});