React earlier to hardcore danger and audit native retreat outcomes
Core tests / core (push) Successful in 48s

This commit is contained in:
Codex
2026-09-24 02:18:25 +12:00
parent ad9f1801e4
commit e7b9df9e08
3 changed files with 93 additions and 15 deletions
+63 -9
View File
@@ -1,6 +1,35 @@
-- Survival assistance for registered living bots only. No teleport, heal, revive or stat changes. -- Survival assistance for registered living bots only. No teleport, heal, revive or stat changes.
local registry,observed,ownedMasters={}, {}, {} local registry,observed,ownedMasters={}, {}, {}
local registryAt=0 local registryAt=0
AuthDBExecute([[CREATE TABLE IF NOT EXISTS azerothai_survival_audit (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
guid INT UNSIGNED,name VARCHAR(64),kind VARCHAR(32),detail VARCHAR(255))]])
local function quoted(s)return "UNHEX('"..string.gsub(tostring(s),'.',function(c)return string.format('%02X',string.byte(c))end).."')" end
local function audit(p,kind,detail)
AuthDBExecute('INSERT INTO azerothai_survival_audit(guid,name,kind,detail) VALUES ('..p:GetGUIDLow()..','..quoted(p:GetName())..','..quoted(kind)..','..quoted(detail)..')')
end
local function dangerous(p,enemy)
if not enemy then return false end
local creature=enemy:ToCreature()
local rank=creature and creature:GetRank() or 0
return enemy:GetLevel()>=p:GetLevel()+3 or rank==1 or rank==2 or rank==3
end
local function dangerousSelection(p)
local target=p:GetSelection()
if not target or not dangerous(p,target) then return false end
for _,enemy in pairs(p:GetUnfriendlyUnitsInRange(40)) do
if enemy:GetGUID()==target:GetGUID() then return true end
end
return false
end
local function outcome(p,r,now)
local t=r.retreat
if not t then return end
local moved=p:GetMapId()~=t.map or (p:GetX()-t.x)^2+(p:GetY()-t.y)^2>=25
t.moved=t.moved or moved
local result=p:IsDead() and 'died' or not p:IsInCombat() and 'out-of-combat' or now-t.at>=30 and (t.moved and 'moved-still-in-combat' or 'no-movement')
if result then audit(p,result,'Retreat observed for '..(now-t.at)..'s; movement='..tostring(t.moved==true));r.retreat=nil end
end
local enabled,enabledAt=true,0 local enabled,enabledAt=true,0
function AzerothAISetSurvivalEnabled(value) enabled=value==true;enabledAt=os.time() end function AzerothAISetSurvivalEnabled(value) enabled=value==true;enabledAt=os.time() end
local function refresh(now) local function refresh(now)
@@ -28,41 +57,60 @@ 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)
local r=observed[p:GetGUIDLow()] local r=observed[p:GetGUIDLow()]
return r and os.time()-(r.actionAt or 0)<20 and (r.action=='retreat requested' or r.action=='recovering health' or r.action=='regroup requested') or false return r and os.time()-(r.actionAt or 0)<20 and (r.action=='retreat requested' or r.action=='recovering health' or r.action=='regroup requested' or r.action=='waiting for companion' or r.action=='avoiding dangerous target') or false
end end
local function sample() local function sample()
local now=os.time();refresh(now) local now=os.time();refresh(now)
for _,p in pairs(GetPlayersInWorld() or {}) do for _,p in pairs(GetPlayersInWorld() or {}) do
local previous=observed[p:GetGUIDLow()]
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 hp=p:GetHealthPct() local hp=p:GetHealthPct()
if p:IsInCombat() and (hp<40 or r.hp and r.hp-hp>=25) then r.dangerAt=now end if p:IsInCombat() and (hp<55 or r.hp and r.hp-hp>=15) then r.dangerAt=now end
r.hp=hp;r.seen=now r.hp=hp;r.seen=now
local master=ai and ai:GetMaster() local master=ai and ai:GetMaster()
if ownedMasters[id] and (not p:GetGroup() or not master or not master:GetGroup() or master:GetGroup()~=p:GetGroup()) then if ownedMasters[id] and (not p:GetGroup() or not master or not master:GetGroup() or master:GetGroup()~=p:GetGroup()) then
if ai and master and master:IsPlayerbot() and master:GetGUIDLow()==ownedMasters[id] and not ai:HasGameClientMaster() then ai:SetMaster(nil) end if ai and master and master:IsPlayerbot() and master:GetGUIDLow()==ownedMasters[id] and not ai:HasGameClientMaster() then ai:SetMaster(nil) end
ownedMasters[id]=nil ownedMasters[id]=nil
end end
if ai and not ai:HasGameClientMaster() and not p:IsTaxi() and not p:IsCasting() and not p:IsRooted() and not ai:IsMovementImpaired() then if ai and not ai:HasGameClientMaster() and not p:IsTaxi() and not p:IsRooted() and not ai:IsMovementImpaired() then
local g=safeGroup(p) local g=safeGroup(p)
-- Never intervene in a party containing humans, unregistered bots, or offline members. -- Never intervene in a party containing humans, unregistered bots, or offline members.
if not p:GetGroup() or g then if not p:GetGroup() or g then
local peers=g and g:GetMembers() or {p};local weak=0 local peers=g and g:GetMembers() or {p};local weak=0
for _,m in pairs(peers) do if m:GetMapId()==p:GetMapId() and p:GetDistance(m)<40 and m:GetHealthPct()<40 then weak=weak+1 end end for _,m in pairs(peers) do if m:GetMapId()==p:GetMapId() and p:GetDistance(m)<40 and m:GetHealthPct()<40 then weak=weak+1 end end
local enemy=p:GetVictim() local enemy=p:GetVictim()
local overmatched=enemy and enemy:GetLevel()>=p:GetLevel()+4 and hp<65 local overmatched=dangerous(p,enemy)
if p:IsInCombat() and (hp<30 or overmatched or #peers>1 and weak>=math.max(2,math.ceil(#peers/2))) and now-(r.retreatAt or 0)>=20 then if p:IsInCombat() and (hp<50 or overmatched or #peers>1 and weak>=math.max(2,math.ceil(#peers/2))) and now-(r.retreatAt or 0)>=8 then
r.dangerAt=now;r.retreatAt=now;r.action='retreat requested';r.actionAt=now r.dangerAt=now;r.retreatAt=now;r.action='retreat requested';r.actionAt=now
if p:IsCasting() then p:StopSpellCast() end
r.accepted=ai:DoSpecificAction('flee',true) r.accepted=ai:DoSpecificAction('flee',true)
elseif not p:IsInCombat() and hp<65 and now-(r.dangerAt or 0)<600 and now-(r.restAt or 0)>=20 then audit(p,'retreat-request', 'accepted='..tostring(r.accepted)..'; hp='..math.floor(hp)..'; dangerous='..tostring(overmatched))
if not r.retreat then r.retreat={at=now,map=p:GetMapId(),x=p:GetX(),y=p:GetY()} end
elseif not p:IsInCombat() and dangerousSelection(p) and now-(r.avoidAt or 0)>=8 then
if p:IsCasting() then p:StopSpellCast() end
p:AttackStop();r.avoidAt=now;r.dangerAt=now;r.action='avoiding dangerous target';r.actionAt=now
r.accepted=ai:DoSpecificAction('flee',true)
audit(p,'avoid-request','accepted='..tostring(r.accepted))
elseif not p:IsCasting() and not p:IsInCombat() and hp<80 and now-(r.dangerAt or 0)<600 and now-(r.restAt or 0)>=20 then
r.restAt=now;r.action='recovering health';r.actionAt=now r.restAt=now;r.action='recovering health';r.actionAt=now
ai:DoSpecificAction('eat',true);ai:DoSpecificAction('drink',true) ai:DoSpecificAction('eat',true);ai:DoSpecificAction('drink',true)
elseif enabled and now-enabledAt<180 and g and not p:IsInCombat() then elseif enabled and now-enabledAt<180 and g and not p:IsInCombat() and not p:IsCasting() then
local leader=ai:GetGroupLeader() local leader=ai:GetGroupLeader()
if leader and leader~=p and managed(leader) and not leader:IsInCombat() and not leader:IsTaxi() and leader:GetMapId()==p:GetMapId() and leader:GetPhaseMask()==p:GetPhaseMask() and p:GetDistance(leader)>18 and p:GetDistance(leader)<90 and hp>=65 and now-(r.followAt or 0)>=20 then if leader and leader~=p and managed(leader) and not leader:IsInCombat() and not leader:IsTaxi() and leader:GetMapId()==p:GetMapId() and leader:GetPhaseMask()==p:GetPhaseMask() and p:GetDistance(leader)>12 and p:GetDistance(leader)<200 and hp>=65 and now-(r.followAt or 0)>=8 then
ai:SetMaster(leader);ownedMasters[id]=leader:GetGUIDLow() ai:SetMaster(leader);ownedMasters[id]=leader:GetGUIDLow()
ai:ChangeStrategy('-stay',1) ai:ChangeStrategy('-stay',1)
r.followAt=now;r.action='regroup requested';r.actionAt=now;r.accepted=ai:DoSpecificAction('follow',true) r.followAt=now;r.action='regroup requested';r.actionAt=now;r.accepted=ai:DoSpecificAction('follow',true)
elseif leader==p and hp>=80 then
for _,m in pairs(peers) do
if m~=p and not m:IsInCombat() and not m:IsTaxi() and m:GetMapId()==p:GetMapId() and m:GetPhaseMask()==p:GetPhaseMask() and p:GetDistance(m)>25 and p:GetDistance(m)<200 then
r.waitSince=r.waitSince or now
if now-r.waitSince<20 then p:MoveStop();r.action='waiting for companion';r.actionAt=now
elseif now-r.waitSince>60 then r.waitSince=nil end
break
end
end
end end
end end
end end
@@ -71,5 +119,11 @@ local function sample()
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
end end
CreateLuaEvent(function()local ok,err=pcall(sample);if not ok then PrintError('[AzerothAI survival] '..tostring(err)) end end,10000,0) CreateLuaEvent(function()local ok,err=pcall(sample);if not ok then PrintError('[AzerothAI survival] '..tostring(err)) end end,2000,0)
PrintInfo('[AzerothAI] Survival assistance enabled: danger memory, native retreat and nearby regrouping') PrintInfo('[AzerothAI] Survival assistance enabled: danger memory, native retreat and nearby regrouping')
CreateLuaEvent(function() AuthDBExecute("DELETE FROM azerothai_survival_audit WHERE at<NOW()-INTERVAL 7 DAY LIMIT 2000") end,3600000,0)
local function deathResult(_,killer,p) local r=observed[p:GetGUIDLow()];if r and r.retreat then audit(p,'died','Died after retreat request');r.retreat=nil end end
RegisterPlayerEvent(8,deathResult)
RegisterPlayerEvent(6,deathResult)
+12 -3
View File
@@ -1,12 +1,12 @@
# Survival and cooperation # Survival and cooperation
Native survival assistance samples registered, living bots every ten seconds. Health below 40% in combat or a drop of at least 25 percentage points records ten minutes of recent danger. Party selection gives those residents priority; recent danger bypasses sociability reluctance, but never hostility, faction/level compatibility, proximity or human-control checks. Candidate ranking favors shared active quest IDs and complementary native healer/tank roles. It does not change class, spec, gear or quest state. Native survival assistance samples registered, living bots every two seconds. Health below 55% in combat or a drop of at least 15 percentage points records ten minutes of recent danger. Party selection gives those residents priority; recent danger bypasses sociability reluctance, but never hostility, faction/level compatibility, proximity or human-control checks. Candidate ranking favors shared active quest IDs and complementary native healer/tank roles. It does not change class, spec, gear or quest state.
Party actions remain receipt-confirmed, one per community tick. Participants wait two minutes after a completed action or one minute after a rejected command before another invitation; pending commands remain excluded. Separated parties now have twenty minutes before automatic departure, giving native regrouping a chance. Declined candidate pairs no longer prevent the same tick from considering a better match. Party actions remain receipt-confirmed, one per community tick. Participants wait two minutes after a completed action or one minute after a rejected command before another invitation; pending commands remain excluded. Separated parties now have twenty minutes before automatic departure, giving native regrouping a chance. Declined candidate pairs no longer prevent the same tick from considering a better match.
The native guard requests the existing `flee` action at most every twenty seconds when health falls below 30%, an enemy is at least four levels higher and health is below 65%, or at least two members and at least half the party are nearby and below 40%. This is a movement request, not guaranteed escape. After danger, injured bots request normal eating/drinking actions; food, mana and other game requirements still apply. No health, level or resurrection changes occur. The native guard requests the existing `flee` action at most every eight seconds when health falls below 50%, an enemy is at least three levels higher or is elite/rare-elite/world-boss, or at least two members and at least half the party are nearby and below 40%. This is a movement request, not guaranteed escape. After danger, injured bots request normal eating/drinking actions; food, mana and other game requirements still apply. No health, level or resurrection changes occur.
Healthy, noncombat followers 18–90 yards from their bot leader on the same map/phase can request normal follow movement. Human-led, mixed, raid and partly offline parties are excluded. The guard does not teleport or force cross-map travel. A master relationship assigned for following is cleared when that party relationship ends. Slower model commands yield to a recent native survival request rather than immediately replacing it. An accepted request does not establish shared quest completion or successful escape; existing gameplay and cooperation counters remain the outcome evidence. Healthy, noncombat followers 12–200 yards from their bot leader on the same map/phase can request normal follow movement. Human-led, mixed, raid and partly offline parties are excluded. The guard does not teleport or force cross-map travel. A master relationship assigned for following is cleared when that party relationship ends. Slower model commands yield to a recent native survival request rather than immediately replacing it. An accepted request does not establish shared quest completion or successful escape; existing gameplay and cooperation counters remain the outcome evidence.
Grouping assistance uses the existing parties-enabled setting, refreshed by the operations collector with a three-minute expiry. Danger detection and emergency self-preservation remain active for eligible bots. Bot Activity displays recent survival requests explicitly as requests. The ALE bridge supplies native combat role and up to 25 active quest IDs for companion matching. Grouping assistance uses the existing parties-enabled setting, refreshed by the operations collector with a three-minute expiry. Danger detection and emergency self-preservation remain active for eligible bots. Bot Activity displays recent survival requests explicitly as requests. The ALE bridge supplies native combat role and up to 25 active quest IDs for companion matching.
@@ -19,3 +19,12 @@ Install `ale/azerothai_parties.lua` beside the bridge and reload ALE before depl
Party invitations now carry both exact native identities and expire 45 seconds after creation. The native guard retries every two seconds while a participant is busy or out of range, allowing a meeting within 60 yards on the same map/phase. Every attempt rechecks identity, lifecycle, faction, levels, human control and group capacity. There is no teleport or forced combat interruption. A deferred bridge command receives its completion receipt only when membership is confirmed or the invitation expires/fails. Pending participants remain reserved so another invitation cannot race the first. ALE reloads lose in-memory retries; the durable lane eventually replays the expired request as a failure rather than applying an old invitation. Party invitations now carry both exact native identities and expire 45 seconds after creation. The native guard retries every two seconds while a participant is busy or out of range, allowing a meeting within 60 yards on the same map/phase. Every attempt rechecks identity, lifecycle, faction, levels, human control and group capacity. There is no teleport or forced combat interruption. A deferred bridge command receives its completion receipt only when membership is confirmed or the invitation expires/fails. Pending participants remain reserved so another invitation cannot race the first. ALE reloads lose in-memory retries; the durable lane eventually replays the expired request as a failure rather than applying an old invitation.
Every ten seconds, the party guard checks incomplete groups against the complete persisted character membership and hardcore registry. It removes only a verified permanently dead member, after validating every identity and protecting human, unknown, active-but-offline, raid and combat parties. One mutation per group avoids touching a Group object after native disbanding. The audit table `acore_auth.azerothai_party_audit` records actual removals and invitation outcomes; it is covered by the full auth database backup. Every ten seconds, the party guard checks incomplete groups against the complete persisted character membership and hardcore registry. It removes only a verified permanently dead member, after validating every identity and protecting human, unknown, active-but-offline, raid and combat parties. One mutation per group avoids touching a Group object after native disbanding. The audit table `acore_auth.azerothai_party_audit` records actual removals and invitation outcomes; it is covered by the full auth database backup.
## Hardcore survival revision
The native guard now samples every two seconds. Retreat is requested below 50% health, against targets at least three levels higher or elite/rare-elite/world-boss targets, or when nearby party health collapses. Emergency casts are stopped before requesting flee; human-controlled, mixed/incomplete parties, taxi and movement restrictions remain protected. Requests are limited to one per eight seconds. Dangerous selected targets must also appear in the native unfriendly list within 40 yards before pre-combat avoidance is requested. These are native movement requests, not guaranteed avoidance or escape.
Healthy followers can regroup over 12–200 yards on the same map/phase. Healthy noncombat leaders pause for at most 20 seconds per minute when a noncombat companion is 25–200 yards behind. Recovery requests now extend to 80% health after recent danger. No teleport, resurrection, population or stat changes are made.
The auth table `azerothai_survival_audit` records request acceptance and observed outcomes: out of combat, movement while still in combat, no movement after 30 seconds, or death during observation. Movement means at least five yards from the starting position (or a map change); it does not prove successful escape. Audit records expire after seven days in bounded hourly batches. Snapshots continue to expose the latest action and timestamp.
+18 -3
View File
@@ -4,7 +4,9 @@ setup='''
now=1000;os.time=function()return now end;calls={};people={} now=1000;os.time=function()return now end;calls={};people={}
function AuthDBQuery(sql)local i=1;return {GetUInt32=function(_,n)return n==0 and i or 10+i end,GetString=function()return i==1 and 'A' or 'B'end,NextRow=function()i=i+1;return i<=2 end} end function AuthDBQuery(sql)local i=1;return {GetUInt32=function(_,n)return n==0 and i or 10+i end,GetString=function()return i==1 and 'A' or 'B'end,NextRow=function()i=i+1;return i<=2 end} end
function GetPlayersInWorld()return people end function GetPlayersInWorld()return people end
function CreateLuaEvent(fn)tick=fn end function CreateLuaEvent(fn,ms)if ms==2000 then tick=fn end end
function RegisterPlayerEvent()end
auditRows={};function AuthDBExecute(sql)table.insert(auditRows,sql)end
function PrintInfo()end function PrintInfo()end
function PrintError(s)error(s)end function PrintError(s)error(s)end
function make(id,name) function make(id,name)
@@ -13,6 +15,9 @@ function make(id,name)
function p:IsPlayerbot()return not self.human end;function p:IsDead()return self.dead end;function p:GetBotAI()return self.ai end function p:IsPlayerbot()return not self.human end;function p:IsDead()return self.dead end;function p:GetBotAI()return self.ai end
function p:GetHealthPct()return self.hp end;function p:IsInCombat()return self.combat end function p:GetHealthPct()return self.hp end;function p:IsInCombat()return self.combat end
function p:IsTaxi()return self.taxi end;function p:IsCasting()return self.cast end;function p:IsRooted()return false end function p:IsTaxi()return self.taxi end;function p:IsCasting()return self.cast end;function p:IsRooted()return false end
function p:GetX()return self.x or 0 end;function p:GetY()return 0 end
function p:GetUnfriendlyUnitsInRange()return self.friendly and {} or self.selected and {self.selected} or {} end;function p:GetSelection()return self.selected end;function p:StopSpellCast()self.cast=false;self.interrupted=true end
function p:AttackStop()self.attackStopped=true end;function p:MoveStop()self.waited=true end
function p:GetGroup()return self.group end;function p:GetVictim()return self.enemy end;function p:GetLevel()return 5 end function p:GetGroup()return self.group end;function p:GetVictim()return self.enemy end;function p:GetLevel()return 5 end
function p:GetDistance()return self.distance end;function p:GetMapId()return 0 end;function p:GetPhaseMask()return 1 end function p:GetDistance()return self.distance end;function p:GetMapId()return 0 end;function p:GetPhaseMask()return 1 end
function p.ai:GetMaster()return p.master end;function p.ai:SetMaster(m)p.master=m end function p.ai:GetMaster()return p.master end;function p.ai:SetMaster(m)p.master=m end
@@ -27,11 +32,21 @@ function step(n)now=now+n;tick()end
''' '''
def new(): def new():
l=LuaRuntime();l.execute(setup);l.execute((Path(__file__).resolve().parents[1]/'ale/azerothai_survival.lua').read_text());l.execute('AzerothAISetSurvivalEnabled(true)');return l l=LuaRuntime();l.execute(setup);l.execute((Path(__file__).resolve().parents[1]/'ale/azerothai_survival.lua').read_text());l.execute('AzerothAISetSurvivalEnabled(true)');return l
l=new();l.execute("A.combat=true;A.hp=20;step(0);assert(calls[1]=='flee');step(10);assert(#calls==1);step(10);assert(#calls==2)") l=new();l.execute("A.combat=true;A.hp=20;step(0);assert(calls[1]=='flee');step(2);assert(#calls==1);step(6);assert(#calls==2)")
l=new();l.execute("group();A.human=true;B.combat=true;B.hp=20;step(0);assert(#calls==0)") l=new();l.execute("group();A.human=true;B.combat=true;B.hp=20;step(0);assert(#calls==0)")
l=new();l.execute("group();step(0);assert(calls[1]=='follow' and B.master==A)") l=new();l.execute("group();step(0);assert(calls[1]=='follow' and B.master==A)")
l=new();l.execute("group();B.distance=100;step(0);assert(#calls==0)") l=new();l.execute("group();B.distance=250;step(0);assert(#calls==0)")
l=new();l.execute("A.combat=true;A.hp=20;A.controlled=true;step(0);assert(#calls==0)") l=new();l.execute("A.combat=true;A.hp=20;A.controlled=true;step(0);assert(#calls==0)")
l=new();l.execute("A.combat=true;A.hp=20;step(0);A.combat=false;step(20);assert(calls[2]=='eat' and calls[3]=='drink')") l=new();l.execute("A.combat=true;A.hp=20;step(0);A.combat=false;step(20);assert(calls[2]=='eat' and calls[3]=='drink')")
l=new();l.execute("group();AzerothAISetSurvivalEnabled(false);step(0);assert(#calls==0)") l=new();l.execute("group();AzerothAISetSurvivalEnabled(false);step(0);assert(#calls==0)")
print('Native survival: retreat cooldown, recovery, regroup range, human-party protection, and disabled grouping passed') print('Native survival: retreat cooldown, recovery, regroup range, human-party protection, and disabled grouping passed')
l=new();l.execute("A.combat=true;A.hp=45;A.cast=true;step(0);assert(A.interrupted and calls[1]=='flee')")
l=new();l.execute("A.combat=true;A.hp=100;A.enemy={GetLevel=function()return 8 end,ToCreature=function()return nil end,IsHostileTo=function()return true end};step(0);assert(calls[1]=='flee')")
l=new();l.execute("A.selected={GetGUID=function()return 99 end,GetLevel=function()return 5 end,ToCreature=function(self)return self end,GetRank=function()return 1 end,IsHostileTo=function()return true end};step(0);assert(A.attackStopped and calls[1]=='flee')")
l=new();l.execute("A.friendly=true;A.selected={GetLevel=function()return 80 end,ToCreature=function()return nil end};step(0);assert(#calls==0)")
l=new();l.execute("A.combat=true;A.hp=40;step(0);A.x=10;step(2);A.combat=false;step(2);assert(string.find(auditRows[#auditRows], '6F75742D6F662D636F6D626174'))")
l=new();l.execute("A.combat=true;A.hp=40;step(0);A.hp=70;step(30);assert(string.find(auditRows[#auditRows], '6E6F2D6D6F76656D656E74'))")
l=new();l.execute("group();B.distance=150;step(0);assert(B.master==A)")
l=new();l.execute("group();A.distance=40;step(0);assert(A.waited);A.waited=false;step(22);assert(not A.waited)")
print('Earlier retreat, casting interruption, hostile/elite avoidance, bounded leader waiting, longer regroup and observed retreat outcomes passed')