Persist fatal encounters and audit resident readiness with party outcome metrics
Core tests / core (push) Successful in 42s
Core tests / core (push) Successful in 42s
This commit is contained in:
@@ -147,12 +147,19 @@ local function world_payload(player)
|
|||||||
p.humanControlled=ai and ai:HasGameClientMaster() or false
|
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
|
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 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()
|
local g=player:GetGroup()
|
||||||
p.groupId=g and tostring(g:GetGUID()) or ""
|
p.groupId=g and tostring(g:GetGUID()) or ""
|
||||||
local members={}
|
local members={}
|
||||||
if g then for _,m in pairs(g:GetMembers()) do members[#members+1]=m:GetName() end end
|
if g then for _,m in pairs(g:GetMembers()) do members[#members+1]=m:GetName() end end
|
||||||
p.groupNames=table.concat(members,",")
|
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)
|
p.nearbyBots=nearby_bot_names(player)
|
||||||
return p
|
return p
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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()<m.level+3 and (target:GetX()-m.x)^2+(target:GetY()-m.y)^2<=10000 then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
function AzerothAIExperienceSnapshot(p)
|
||||||
|
local m=memories(p)[1]
|
||||||
|
if m then return {deathMemoryId=m.id,deathMemoryAt=m.at,deathMemoryKiller=m.killer,deathMemoryMap=m.map,deathMemoryX=m.x,deathMemoryY=m.y,deathMemoryLevel=m.level} end
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
local function died(_,killer,p)
|
||||||
|
if not identity(p) then return end
|
||||||
|
local now=os.time();local c=cache[p:GetGUIDLow()]
|
||||||
|
if c and c.recorded==now then return end
|
||||||
|
local creature=killer and killer:ToCreature()
|
||||||
|
AuthDBExecute('INSERT INTO azerothai_death_experience(guid,account_id,name,at,killer,entry,map,x,y,level) VALUES ('..p:GetGUIDLow()..','..p:GetAccountId()..','..quote(p:GetName())..','..now..','..quote(killer and killer:GetName() or 'Unknown')..','..(creature and creature:GetEntry() or 0)..','..p:GetMapId()..','..p:GetX()..','..p:GetY()..','..p:GetLevel()..')')
|
||||||
|
cache[p:GetGUIDLow()]={loaded=0,recorded=now,rows={}}
|
||||||
|
end
|
||||||
|
RegisterPlayerEvent(8,died)
|
||||||
|
RegisterPlayerEvent(6,died)
|
||||||
|
CreateLuaEvent(function()
|
||||||
|
AuthDBExecute('DELETE FROM azerothai_death_experience WHERE at<'..(os.time()-30*86400)..' LIMIT 2000')
|
||||||
|
for guid,c in pairs(cache) do if os.time()-c.loaded>600 then cache[guid]=nil end end
|
||||||
|
end,3600000,0)
|
||||||
@@ -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)
|
||||||
@@ -55,7 +55,7 @@ local function dangerousSelection(p,r,now)
|
|||||||
local target=p:GetSelection()
|
local target=p:GetSelection()
|
||||||
if not target then return false end
|
if not target then return false end
|
||||||
local key=tostring(target:GetGUID())
|
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
|
for _,enemy in pairs(p:GetUnfriendlyUnitsInRange(40)) do
|
||||||
if enemy:GetGUID()==target:GetGUID() then return true end
|
if enemy:GetGUID()==target:GetGUID() then return true end
|
||||||
end
|
end
|
||||||
@@ -91,7 +91,7 @@ end
|
|||||||
function AzerothAISurvivalState(p)
|
function AzerothAISurvivalState(p)
|
||||||
local r=observed[p:GetGUIDLow()]
|
local r=observed[p:GetGUIDLow()]
|
||||||
if not r then return 0,'',0 end
|
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
|
end
|
||||||
-- Prevent slower model intentions from undoing an immediate safety action.
|
-- Prevent slower model intentions from undoing an immediate safety action.
|
||||||
function AzerothAISurvivalOverride(p)
|
function AzerothAISurvivalOverride(p)
|
||||||
@@ -105,6 +105,7 @@ local function sample()
|
|||||||
if previous then outcome(p,previous,now) end
|
if previous then outcome(p,previous,now) end
|
||||||
if managed(p) then
|
if managed(p) then
|
||||||
local id=p:GetGUIDLow();local ai=p:GetBotAI();local r=observed[id] or {};observed[id]=r
|
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 hp=p:GetHealthPct()
|
||||||
local falling=r.hp and r.hp-hp>=20
|
local falling=r.hp and r.hp-hp>=20
|
||||||
local mana=p:GetPowerType()==0 and p:GetPowerPct(0) or 100
|
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
|
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 attackers=0;for _ in pairs(p:GetAttackers()) do attackers=attackers+1 end
|
||||||
local enemy=p:GetVictim()
|
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 {}
|
r.avoidTargets=r.avoidTargets or {}
|
||||||
for key,expires in pairs(r.avoidTargets) do if expires<=now then r.avoidTargets[key]=nil end end
|
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
|
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
|
end
|
||||||
end
|
end
|
||||||
|
if r.actionAt and (r.action~=previousAction or now-previousAt>30) then r.actionSince=now end
|
||||||
end
|
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
|
for id,r in pairs(observed) do if now-(r.seen or 0)>600 then observed[id]=nil;ownedMasters[id]=nil end end
|
||||||
|
|||||||
@@ -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 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 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<string,number>={};for(const e of aggregates.results)totals[e.kind]=(totals[e.kind]??0)+e.amount;
|
const totals:Record<string,number>={};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 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);
|
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'}});
|
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'}});
|
||||||
|
|||||||
@@ -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));
|
const attempts=recoveries.filter(r=>r.name.toLowerCase().includes(query.toLowerCase())&&(filter==='all'||r.status===filter));
|
||||||
return <div className="diagnostics"><div className="diag-hero"><div><div className="eyebrow">EVIDENCE FROM YOUR REALM</div><h2>{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.'}</h2><p>{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.'}</p></div><span className={'diag-status '+(stale?'warning':'good')}><span/>{stale?'Collection delayed':'Collector active'}</span></div><p className="diag-updated"><Clock3 size={14}/> Last collection {stamp(data.collectedAt)} · View refreshes every 15 seconds</p>{error&&<div className="notice" role="alert">{error}</div>}
|
return <div className="diagnostics"><div className="diag-hero"><div><div className="eyebrow">EVIDENCE FROM YOUR REALM</div><h2>{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.'}</h2><p>{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.'}</p></div><span className={'diag-status '+(stale?'warning':'good')}><span/>{stale?'Collection delayed':'Collector active'}</span></div><p className="diag-updated"><Clock3 size={14}/> Last collection {stamp(data.collectedAt)} · View refreshes every 15 seconds</p>{error&&<div className="notice" role="alert">{error}</div>}
|
||||||
{page==='Needs attention'&&<><div className="diag-metrics"><Metric label="Needs attention" value={data.attention.length} note="Current signals, not historical totals" icon={AlertTriangle} tone={data.attention.length?'warning':'good'}/><Metric label="Critical" value={critical} note="Resource pressure or population over cap" icon={ShieldCheck} tone={critical?'critical':''}/><Metric label="Online residents" value={o.bots.length} note="Target: 50 · 25 per faction" icon={Users}/><Metric label="Observed quest rewards" value={metrics.rewarded??0} note="New rewarded quest records · 24 hours" icon={ScrollText}/></div><div className="diag-section-title"><h2>Follow-up queue</h2><span className="fineprint">Critical signals first</span></div>{data.attention.length?<div className="diag-alerts">{data.attention.map(a=><button className={'diag-alert '+a.severity} key={a.key} onClick={()=>onNavigate(a.target,a.name)}><AlertTriangle size={20}/><div><span className="diag-label">{a.severity}</span><h3>{a.title}</h3><p>{a.detail}</p><small>Open {a.target}</small></div><ArrowRight size={18}/></button>)}</div>:<div className="diag-clear"><CheckCircle2 size={36}/><h3>No current attention signals</h3><p>The monitored checks are clear. This is not proof that every gameplay path is bug-free.</p></div>}<div className="diag-links">{['Gameplay Results','Recovery Results','Bot Performance'].map(p=><button key={p} onClick={()=>onNavigate(p)}>{p}<ArrowRight size={16}/></button>)}</div></>}
|
{page==='Needs attention'&&<><div className="diag-metrics"><Metric label="Needs attention" value={data.attention.length} note="Current signals, not historical totals" icon={AlertTriangle} tone={data.attention.length?'warning':'good'}/><Metric label="Critical" value={critical} note="Resource pressure or population over cap" icon={ShieldCheck} tone={critical?'critical':''}/><Metric label="Online residents" value={o.bots.length} note="Target: 50 · 25 per faction" icon={Users}/><Metric label="Observed quest rewards" value={metrics.rewarded??0} note="New rewarded quest records · 24 hours" icon={ScrollText}/></div><div className="diag-section-title"><h2>Follow-up queue</h2><span className="fineprint">Critical signals first</span></div>{data.attention.length?<div className="diag-alerts">{data.attention.map(a=><button className={'diag-alert '+a.severity} key={a.key} onClick={()=>onNavigate(a.target,a.name)}><AlertTriangle size={20}/><div><span className="diag-label">{a.severity}</span><h3>{a.title}</h3><p>{a.detail}</p><small>Open {a.target}</small></div><ArrowRight size={18}/></button>)}</div>:<div className="diag-clear"><CheckCircle2 size={36}/><h3>No current attention signals</h3><p>The monitored checks are clear. This is not proof that every gameplay path is bug-free.</p></div>}<div className="diag-links">{['Gameplay Results','Recovery Results','Bot Performance'].map(p=><button key={p} onClick={()=>onNavigate(p)}>{p}<ArrowRight size={16}/></button>)}</div></>}
|
||||||
{page==='Gameplay Results'&&<><div className="diag-metrics"><Metric label="Quest rewards" value={metrics.rewarded??0} note="New rewarded quest records" icon={ScrollText}/><Metric label="Items looted" value={metrics.lootItems??0} note={`${metrics.lootEvents??0} native loot collections`} icon={Package}/><Metric label="Currency looted" value={formatCopper(metrics.lootedCopper??0)} note="Looted coins only · excludes quest rewards and sales" icon={Package}/><Metric label="Shared-target kills" value={metrics.partyKills??0} note="Killer had a nearby bot fighting the same target" icon={Users}/><Metric label="Quests abandoned" value={metrics.abandoned??0} note="An abandonment is not automatically a bug" icon={Activity}/></div><div className="diag-note">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: <strong>{metrics.quests??0}</strong>. Party actions rejected: <strong>{metrics.partyFailed??0}</strong>. Currency tracking starts with this update; historical coin loot is unavailable. Gaps or a server reload can miss unflushed events.</div><div className="diag-toolbar"><label className="diag-search"><Search size={17}/><input aria-label="Search gameplay results" placeholder="Find a resident or zone…" value={query} onChange={e=>setQuery(e.target.value)}/></label><select aria-label="Filter gameplay results" value={filter} onChange={e=>setFilter(e.target.value)}><option value="all">All online residents</option><option value="grouped">Currently grouped</option></select></div><div className="diag-bot-grid">{visibleBots.map(b=><section className="diag-bot" key={b.name}><div className="diag-bot-head"><span className="diag-avatar">{b.name[0]}</span><div><h3>{b.name}</h3><small>{b.zone}</small></div><span className={'diag-chip '+(b.grouped?'good':'')}>{b.grouped?'Grouped':'Solo / unknown'}</span></div><div className="diag-bot-values"><div><strong>{b.totals.rewarded}</strong><small>Quest rewards</small></div><div><strong>{b.totals.lootItems}</strong><small>Items looted</small></div><div><strong>{formatCopper(b.totals.lootedCopper??0)}</strong><small>Currency looted</small></div><div><strong>{b.totals.partyKills}</strong><small>Shared kills</small></div></div><details><summary>Evidence details</summary><p>Objectives completed: {b.totals.quests} · Abandoned: {b.totals.abandoned} · Loot collections: {b.totals.lootEvents}</p><p>Observed bot companions: {b.groupBots.filter(n=>n!==b.name).join(', ')||'None reported'}</p><button className="diag-text-link" onClick={()=>onNavigate('Bot Activity',b.name)}>View activity <ArrowRight size={14}/></button></details></section>)}</div>{!visibleBots.length&&<div className="empty-note">No residents match your filter.</div>}<details className="diag-timeline"><summary>Recent evidence ({o.recent.length})</summary>{o.recent.map(e=><div key={e.id}><small>{stamp(e.at)}</small><strong>{e.name}</strong><p>{e.detail} · +{e.kind==='lootedCopper'?formatCopper(e.amount):e.amount}</p></div>)}</details></>}
|
{page==='Gameplay Results'&&<><div className="diag-metrics"><Metric label="Quest rewards" value={metrics.rewarded??0} note="New rewarded quest records" icon={ScrollText}/><Metric label="Items looted" value={metrics.lootItems??0} note={`${metrics.lootEvents??0} native loot collections`} icon={Package}/><Metric label="Currency looted" value={formatCopper(metrics.lootedCopper??0)} note="Looted coins only · excludes quest rewards and sales" icon={Package}/><Metric label="Shared-target kills" value={metrics.partyKills??0} note="Killer had a nearby bot fighting the same target" icon={Users}/><Metric label="Shared quest completions" value={metrics.sharedQuests??0} note="Confirmed pairs completing the same quest together" icon={ScrollText}/><Metric label="Time near companions" value={`${Math.floor((metrics.nearPartySeconds??0)/60)} min`} note="Sampled resident-minutes within 35 yards · totals include each bot" icon={Users}/><Metric label="Quests abandoned" value={metrics.abandoned??0} note="An abandonment is not automatically a bug" icon={Activity}/></div><div className="diag-note">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: <strong>{metrics.quests??0}</strong>. Party actions rejected: <strong>{metrics.partyFailed??0}</strong>. Currency tracking starts with this update; historical coin loot is unavailable. Gaps or a server reload can miss unflushed events.</div><div className="diag-toolbar"><label className="diag-search"><Search size={17}/><input aria-label="Search gameplay results" placeholder="Find a resident or zone…" value={query} onChange={e=>setQuery(e.target.value)}/></label><select aria-label="Filter gameplay results" value={filter} onChange={e=>setFilter(e.target.value)}><option value="all">All online residents</option><option value="grouped">Currently grouped</option></select></div><div className="diag-bot-grid">{visibleBots.map(b=><section className="diag-bot" key={b.name}><div className="diag-bot-head"><span className="diag-avatar">{b.name[0]}</span><div><h3>{b.name}</h3><small>{b.zone}</small></div><span className={'diag-chip '+(b.grouped?'good':'')}>{b.grouped?'Grouped':'Solo / unknown'}</span></div><div className="diag-bot-values"><div><strong>{b.totals.rewarded}</strong><small>Quest rewards</small></div><div><strong>{b.totals.lootItems}</strong><small>Items looted</small></div><div><strong>{formatCopper(b.totals.lootedCopper??0)}</strong><small>Currency looted</small></div><div><strong>{b.totals.partyKills}</strong><small>Shared kills</small></div></div><details><summary>Evidence details</summary><p>Objectives completed: {b.totals.quests} · Abandoned: {b.totals.abandoned} · Loot collections: {b.totals.lootEvents}</p><p>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.</p><p>Observed bot companions: {b.groupBots.filter(n=>n!==b.name).join(', ')||'None reported'}</p><button className="diag-text-link" onClick={()=>onNavigate('Bot Activity',b.name)}>View activity <ArrowRight size={14}/></button></details></section>)}</div>{!visibleBots.length&&<div className="empty-note">No residents match your filter.</div>}<details className="diag-timeline"><summary>Recent evidence ({o.recent.length})</summary>{o.recent.map(e=><div key={e.id}><small>{stamp(e.at)}</small><strong>{e.name}</strong><p>{e.detail} · +{e.kind==='lootedCopper'?formatCopper(e.amount):e.amount}</p></div>)}</details></>}
|
||||||
{page==='Recovery Results'&&<><div className="diag-metrics">{['resumed','no_progress','pending','unverified'].map(status=><Metric key={status} label={labels[status]} value={recoveries.filter(r=>r.status===status).length} note="Among the latest 100 retained attempts" tone={status==='resumed'?'good':status==='no_progress'?'warning':''} icon={status==='resumed'?CheckCircle2:Clock3}/>)}</div><p className="diag-note">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.</p><div className="diag-toolbar"><label className="diag-search"><Search size={17}/><input aria-label="Search recovery results" placeholder="Find a resident…" value={query} onChange={e=>setQuery(e.target.value)}/></label><select aria-label="Filter recovery results" value={filter} onChange={e=>setFilter(e.target.value)}><option value="all">All outcomes</option>{Object.entries(labels).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></div><div className="diag-alerts">{attempts.map((r,i)=><article className="diag-recovery" key={r.at+':'+r.name+':'+i}><div><h3>{r.name}</h3><small>{stamp(r.at)}</small></div><span className={'diag-chip '+(r.status==='resumed'?'good':r.status==='no_progress'?'warning':'')}>{labels[r.status]}</span><p>{r.result}</p><details><summary>Attempt details</summary><p>{r.detail}</p><small>Checked: {stamp(r.checkedAt)}</small></details></article>)}</div>{!attempts.length&&<div className="diag-clear"><ShieldCheck size={36}/><h3>No matching recovery attempts</h3><p>Results appear after the native guard attempts a recovery. No intervention is forced to populate this view.</p></div>}</>}
|
{page==='Recovery Results'&&<><div className="diag-metrics">{['resumed','no_progress','pending','unverified'].map(status=><Metric key={status} label={labels[status]} value={recoveries.filter(r=>r.status===status).length} note="Among the latest 100 retained attempts" tone={status==='resumed'?'good':status==='no_progress'?'warning':''} icon={status==='resumed'?CheckCircle2:Clock3}/>)}</div><p className="diag-note">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.</p><div className="diag-toolbar"><label className="diag-search"><Search size={17}/><input aria-label="Search recovery results" placeholder="Find a resident…" value={query} onChange={e=>setQuery(e.target.value)}/></label><select aria-label="Filter recovery results" value={filter} onChange={e=>setFilter(e.target.value)}><option value="all">All outcomes</option>{Object.entries(labels).map(([k,v])=><option key={k} value={k}>{v}</option>)}</select></div><div className="diag-alerts">{attempts.map((r,i)=><article className="diag-recovery" key={r.at+':'+r.name+':'+i}><div><h3>{r.name}</h3><small>{stamp(r.at)}</small></div><span className={'diag-chip '+(r.status==='resumed'?'good':r.status==='no_progress'?'warning':'')}>{labels[r.status]}</span><p>{r.result}</p><details><summary>Attempt details</summary><p>{r.detail}</p><small>Checked: {stamp(r.checkedAt)}</small></details></article>)}</div>{!attempts.length&&<div className="diag-clear"><ShieldCheck size={36}/><h3>No matching recovery attempts</h3><p>Results appear after the native guard attempts a recovery. No intervention is forced to populate this view.</p></div>}</>}
|
||||||
{page==='Bot Performance'&&<><div className="diag-metrics"><Metric label="Average full tick" value={perf?perf.averageTickMs.toFixed(2)+' ms':'—'} note="Cumulative mean since profiler start/reset" icon={Timer}/><Metric label="Measured full ticks" value={perf?Math.round(perf.ticks).toLocaleString():'—'} note="Bot update cycles, not world-server ticks"/><Metric label="Profiler" value={perf?.enabled?'Enabled':'Unavailable'} note={stamp(perf?.at??null)} tone={perf?.enabled?'good':'warning'}/><Metric label="Stored samples" value={data.performance.history.length} note="Up to 144 snapshots retained" icon={Clock3}/></div>{(!perf||Date.now()/1000-perf.at>720)&&<div className="notice">Profiler data is missing or stale. Measurements below are not current.</div>}<section className="panel"><div className="panel-head"><h2>Average full-tick history</h2><small>Milliseconds · cumulative average</small></div><div className="panel-body"><div className="diag-bars" role="img" aria-label="History of cumulative average bot tick duration">{data.performance.history.slice(-48).map(p=><div key={p.at} title={`${stamp(p.at)}: ${p.averageTickMs.toFixed(3)} ms`} style={{height:Math.max(3,100*p.averageTickMs/Math.max(.001,...data.performance.history.slice(-48).map(x=>x.averageTickMs)))+'%'}}/>)}</div><p className="fineprint">{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.</p></div></section><section className="panel" style={{marginTop:20}}><div className="panel-head"><h2>Most expensive measured operations</h2><small>Sorted by accumulated time</small></div><div className="diag-table-scroll"><table className="diag-table"><thead><tr><th>Operation</th><th>Calls</th><th>Mean</th><th>Maximum</th><th>Per full tick</th></tr></thead><tbody>{perf?.rows.map((r,i)=><tr key={i}><td><strong>{r.name}</strong><small>{r.category}</small></td><td>{Math.round(r.calls).toLocaleString()}</td><td>{r.averageMs.toFixed(3)} ms</td><td>{r.maxMs.toFixed(3)} ms</td><td>{r.perTickMs.toFixed(3)} ms</td></tr>)}</tbody></table></div>{!perf?.rows.length&&<p className="empty-note">Waiting for the first native profiler export.</p>}</section></>}
|
{page==='Bot Performance'&&<><div className="diag-metrics"><Metric label="Average full tick" value={perf?perf.averageTickMs.toFixed(2)+' ms':'—'} note="Cumulative mean since profiler start/reset" icon={Timer}/><Metric label="Measured full ticks" value={perf?Math.round(perf.ticks).toLocaleString():'—'} note="Bot update cycles, not world-server ticks"/><Metric label="Profiler" value={perf?.enabled?'Enabled':'Unavailable'} note={stamp(perf?.at??null)} tone={perf?.enabled?'good':'warning'}/><Metric label="Stored samples" value={data.performance.history.length} note="Up to 144 snapshots retained" icon={Clock3}/></div>{(!perf||Date.now()/1000-perf.at>720)&&<div className="notice">Profiler data is missing or stale. Measurements below are not current.</div>}<section className="panel"><div className="panel-head"><h2>Average full-tick history</h2><small>Milliseconds · cumulative average</small></div><div className="panel-body"><div className="diag-bars" role="img" aria-label="History of cumulative average bot tick duration">{data.performance.history.slice(-48).map(p=><div key={p.at} title={`${stamp(p.at)}: ${p.averageTickMs.toFixed(3)} ms`} style={{height:Math.max(3,100*p.averageTickMs/Math.max(.001,...data.performance.history.slice(-48).map(x=>x.averageTickMs)))+'%'}}/>)}</div><p className="fineprint">{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.</p></div></section><section className="panel" style={{marginTop:20}}><div className="panel-head"><h2>Most expensive measured operations</h2><small>Sorted by accumulated time</small></div><div className="diag-table-scroll"><table className="diag-table"><thead><tr><th>Operation</th><th>Calls</th><th>Mean</th><th>Maximum</th><th>Per full tick</th></tr></thead><tbody>{perf?.rows.map((r,i)=><tr key={i}><td><strong>{r.name}</strong><small>{r.category}</small></td><td>{Math.round(r.calls).toLocaleString()}</td><td>{r.averageMs.toFixed(3)} ms</td><td>{r.maxMs.toFixed(3)} ms</td><td>{r.perTickMs.toFixed(3)} ms</td></tr>)}</tbody></table></div>{!perf?.rows.length&&<p className="empty-note">Waiting for the first native profiler export.</p>}</section></>}
|
||||||
</div>;
|
</div>;
|
||||||
|
|||||||
+7
-1
@@ -24,7 +24,12 @@ 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;
|
if(typeof p.team!=='number'||typeof p.x!=='number'||typeof p.y!=='number')return;
|
||||||
const old=b.world;
|
const old=b.world;
|
||||||
const stationary=old&&old.mapId===p.mapId&&Math.hypot(old.x-p.x,old.y-p.y)<3;
|
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);
|
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(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){
|
if(old&&old.mapId!==b.world.mapId&&b.world.guildId){
|
||||||
@@ -41,6 +46,7 @@ export function applyCooperation(s:State,p:Record<string,unknown>){
|
|||||||
const kind=p.cooperation;
|
const kind=p.cooperation;
|
||||||
if(kind!=='quest'&&kind!=='assist')return;
|
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(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);}
|
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++;}
|
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++;}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-3
@@ -2,7 +2,7 @@ import type {Bot,State} from './types';
|
|||||||
export const DAY=86400,BOUNDARY=6*3600;
|
export const DAY=86400,BOUNDARY=6*3600;
|
||||||
export const reportWindow=(at:number)=>Math.floor((at-BOUNDARY)/DAY)*DAY+BOUNDARY;
|
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 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<string,Progress>;bonds:string[];guilds:Record<string,{goal:string;progress:number}>;social:string[]};
|
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){
|
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 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 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 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 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(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(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==='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&&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;
|
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;
|
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==='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);
|
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;
|
const w=b.world,action=b.lastDecision?.action;
|
||||||
if(b.status!=='online')return {label:b.status,detail:'This resident is not currently online.'};
|
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||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.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.casting)return {label:'Casting',detail:'The game reports a spell cast. Recovery is skipped.'};
|
||||||
if(w.taxi)return {label:'Traveling by taxi',detail:'The game reports taxi travel. Recovery is skipped.'};
|
if(w.taxi)return {label:'Traveling by taxi',detail:'The game reports taxi travel. Recovery is skipped.'};
|
||||||
|
|||||||
@@ -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');
|
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;
|
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');}
|
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<number,Recovery>();for(const r of [...recoveries].sort((a,b)=>b.at-a.at))if(!latest.has(r.guid))latest.set(r.guid,r);
|
const latest=new Map<number,Recovery>();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);
|
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'));
|
return items.sort((a,b)=>Number(b.severity==='critical')-Number(a.severity==='critical'));
|
||||||
|
|||||||
+2
-2
@@ -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 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 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 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 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 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 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 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 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};
|
export type HardcoreSettings = {enabled:boolean; requireVerifiedDeath:boolean; memorialize:boolean};
|
||||||
|
|||||||
@@ -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)
|
source, n = re.subn(pattern, lambda match: line.group(0), source, flags=re.M)
|
||||||
assert n == 1, key
|
assert n == 1, key
|
||||||
(core / 'azerothai-lua/azerothai_bridge.lua').write_text(source)
|
(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',
|
settings = {'AC_AI_PLAYERBOT_DISABLE_RANDOM_LEVELS': '1',
|
||||||
'AC_AI_PLAYERBOT_RANDOMBOT_STARTING_LEVEL': '1',
|
'AC_AI_PLAYERBOT_RANDOMBOT_STARTING_LEVEL': '1',
|
||||||
'AC_AI_PLAYERBOT_DISABLE_DEATH_KNIGHT_LOGIN': '0'}
|
'AC_AI_PLAYERBOT_DISABLE_DEATH_KNIGHT_LOGIN': '0'}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -83,7 +83,7 @@ def public_state(state):
|
|||||||
bot['routine'] = {**pick(b.get('routine', {}), 'wakeHour sleepHour'), 'preferredZones': b.get('routine', {}).get('preferredZones', [])}
|
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')}
|
bot['autonomy'] = {'managed': True, **pick(b.get('autonomy', {}), 'observedAt lastIntentAt nextIntentAt')}
|
||||||
world = b.get('world') or {}
|
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': []}
|
'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', [])]
|
bot['relationships'] = [pick(r, 'targetId affinity note') for r in b.get('relationships', [])]
|
||||||
# Show shared history, never personal whispers or operator-injected memories.
|
# Show shared history, never personal whispers or operator-injected memories.
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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')
|
||||||
@@ -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('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('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');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user