Persist fatal encounters and audit resident readiness with party outcome metrics
Core tests / core (push) Successful in 42s

This commit is contained in:
Codex
2026-09-24 08:05:16 +12:00
parent 900b926b8e
commit d57d793a84
16 changed files with 254 additions and 15 deletions
+8 -1
View File
@@ -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
+48
View File
@@ -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)
+69
View File
@@ -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)
+5 -3
View File
@@ -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
+1 -1
View File
@@ -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'}});
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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.'};
+5 -1
View File
@@ -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
View File
@@ -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};
+2 -1
View File
@@ -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'}
+13
View File
@@ -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.
+1 -1
View File
@@ -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.
+29
View File
@@ -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')
+27
View File
@@ -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')
+18
View File
@@ -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');
});