Support persistent residents with normal resurrection and closer companions
Core tests / core (push) Successful in 2m54s

This commit is contained in:
Codex
2026-09-24 05:33:45 +12:00
parent 0731774457
commit 64e2796a74
11 changed files with 85 additions and 16 deletions
+11 -2
View File
@@ -1,6 +1,13 @@
-- Persistent hardcore retirement. The native random-bot manager fills vacancies
-- from its factory-created, unused starting-level reserve; identities are never reset.
-- Install only after deploy/playable/install-lifecycle.py has seeded the registry.
-- The persisted mode is synchronized from dashboard settings by operations-agent.
AuthDBExecute("CREATE TABLE IF NOT EXISTS azerothai_realm_rules (id TINYINT PRIMARY KEY,hardcore TINYINT NOT NULL)")
AuthDBExecute("INSERT IGNORE INTO azerothai_realm_rules VALUES (1,1)")
function AzerothAIHardcoreEnabled()
local q=AuthDBQuery("SELECT hardcore FROM azerothai_realm_rules WHERE id=1")
return not q or q:GetUInt32(0)~=0
end
local residents = {}
local ready = false
local vetoAt = {}
@@ -68,6 +75,7 @@ local function identity(player)
end
local function recordDeath(player)
if not AzerothAIHardcoreEnabled() then return end
local row,guid=identity(player)
if not row or row.state=="dead" or row.state=="blocked" then return end
row.state="dead"
@@ -77,6 +85,7 @@ local function recordDeath(player)
end
local function enforce()
local hardcore=AzerothAIHardcoreEnabled()
if not ready then reload() end
-- The dashboard may receive a verified death from an older transport backlog
-- after this process seeded its registry. Import terminal states only; never
@@ -114,7 +123,7 @@ local function enforce()
for _,player in pairs(players) do
local guid=player:GetGUIDLow()
local row=residents[guid]
if row and row.state=="active" and row.account==player:GetAccountId() and row.name==player:GetName() and not player:IsDead() then
if row and row.state=="active" and row.account==player:GetAccountId() and row.name==player:GetName() and (not hardcore or not player:IsDead()) then
returning[#returning+1]={player=player,row=row,guid=guid}
end
end
@@ -203,4 +212,4 @@ end,1000,0)
reload()
enforce()
PrintInfo("[AzerothAI] Persistent hardcore retirement and reserve replacement enabled")
PrintInfo("[AzerothAI] Persistent residents and faction cap enabled; hardcore="..tostring(AzerothAIHardcoreEnabled()))
+1 -1
View File
@@ -54,7 +54,7 @@ local function attempt(r)
if not a or not b then return false,'Resident offline' end
if a:GetGUIDLow()~=r.a.guid or a:GetAccountId()~=r.a.account or b:GetGUIDLow()~=r.b.guid or b:GetAccountId()~=r.b.account then return nil,'Identity changed' end
if not resident(a) or not resident(b) then return nil,'Resident retired or human-controlled' end
if a:GetTeam()~=b:GetTeam() or math.abs(a:GetLevel()-b:GetLevel())>10 then return nil,'Faction or level mismatch' end
if a:GetTeam()~=b:GetTeam() or math.abs(a:GetLevel()-b:GetLevel())>5 then return nil,'Faction or level mismatch' end
if not available(a) or not available(b) then return false,'Waiting for combat, cast, or taxi to finish' end
if a:GetMapId()~=b:GetMapId() or a:GetPhaseMask()~=b:GetPhaseMask() or a:GetDistance(b)>60 then return false,'Waiting for a nearby meeting within 60 yards' end
local g=a:GetGroup()
+11 -6
View File
@@ -51,9 +51,11 @@ local function dangerous(p,enemy)
local rank=creature and creature:GetRank() or 0
return enemy:GetLevel()>=p:GetLevel()+2 or rank==1 or rank==2 or rank==3
end
local function dangerousSelection(p)
local function dangerousSelection(p,r,now)
local target=p:GetSelection()
if not target or not dangerous(p,target) then return false end
if not target then return false end
local key=tostring(target:GetGUID())
if not dangerous(p,target) and not (r.avoidTargets and (r.avoidTargets[key] or 0)>now) then return false end
for _,enemy in pairs(p:GetUnfriendlyUnitsInRange(40)) do
if enemy:GetGUID()==target:GetGUID() then return true end
end
@@ -124,9 +126,12 @@ local function sample()
local attackers=0;for _ in pairs(p:GetAttackers()) do attackers=attackers+1 end
local enemy=p:GetVictim()
local overmatched=dangerous(p,enemy)
r.avoidTargets=r.avoidTargets or {}
for key,expires in pairs(r.avoidTargets) do if expires<=now then r.avoidTargets[key]=nil end end
if enemy and (overmatched or attackers>support+1 or falling and hp<80) then r.avoidTargets[tostring(enemy:GetGUID())]=now+600 end
local outnumbered=attackers>support+1 and hp<80
local unsafe=p:IsInCombat() and (hp<(support==0 and 55 or 50) or overmatched or outnumbered or falling and hp<80 or #peers>1 and weak>=math.max(2,math.ceil(#peers/2)))
local avoid=not p:IsInCombat() and dangerousSelection(p)
local avoid=not p:IsInCombat() and dangerousSelection(p,r,now)
if unsafe then r.recoverUntil=now+15 end
if hp<85 or mana<35 then r.resting=true end
if hp>=95 and mana>=65 then r.resting=false end
@@ -155,13 +160,13 @@ local function sample()
end
elseif enabled and now-enabledAt<180 and g and not p:IsInCombat() and not p:IsCasting() then
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)>12 and p:GetDistance(leader)<200 and hp>=95 and mana>=65 and now-(r.followAt or 0)>=8 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)>8 and p:GetDistance(leader)<200 and hp>=95 and mana>=65 and now-(r.followAt or 0)>=8 then
ai:SetMaster(leader);ownedMasters[id]=leader:GetGUIDLow()
ai:ChangeStrategy('-stay',1)
r.followAt=now;r.action='regroup requested';r.actionAt=now;r.accepted=ai:DoSpecificAction('follow',true)
elseif leader==p and hp>=80 then
elseif leader==p and hp>=95 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
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)>15 or m:GetHealthPct()<85) 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
+3 -2
View File
@@ -54,7 +54,7 @@ export function companionScore(a:Bot,b:Bot){
}
const fresh=(b:Bot)=>b.status==='online'&&b.autonomy?.managed&&b.world&&Date.now()/1000-b.world.at<90&&!b.world.inCombat&&!b.world.humanControlled&&!b.world.taxi&&!b.world.casting;
export function partyDecision(a:Bot,b:Bot){
if(!fresh(a)||!fresh(b)||a.id===b.id||a.world!.team!==b.world!.team||Math.abs(a.level-b.level)>10||!a.world!.nearby.includes(b.name)||!b.world!.nearby.includes(a.name))return {accepted:false,reason:'No compatible nearby pair'};
if(!fresh(a)||!fresh(b)||a.id===b.id||a.world!.team!==b.world!.team||Math.abs(a.level-b.level)>5||!a.world!.nearby.includes(b.name)||!b.world!.nearby.includes(a.name))return {accepted:false,reason:'No compatible nearby pair'};
const danger=recentDanger(a)||recentDanger(b);
const willing=(x:Bot,y:Bot)=>affinity(x,y)>=0&&(danger||x.personality.sociability>=35||affinity(x,y)>=5)&&x.personality.activities.some(v=>v==='quest'||v==='explore');
return {accepted:willing(a,b)&&willing(b,a),reason:willing(a,b)&&willing(b,a)?danger?'Seeking safety together after recent danger; compatible faction, levels, and non-hostile companions.':'Both accepted: compatible levels, shared adventure interests, and no hostile bond.':'One character prefers to adventure alone or does not trust the other.'};
@@ -105,7 +105,8 @@ export async function communityTick(store:StateStore,db:D1Database){
for(const a of bots){
if(busy.has(a.id))continue;
// Leave only a bot-only party whose peers have moved away, never a human party.
const far=a.world!.groupId&&a.world!.groupNames.length>1&&a.world!.groupNames.filter(n=>n!==a.name).every(n=>!a.world!.nearby.includes(n));
const recoveringPeer=a.world!.groupNames.some(name=>s.bots.some(peer=>peer.name===name&&peer.status!=='dead'&&peer.world&&Date.now()/1000-peer.world.at<90&&(peer.world.healthPct??100)<=0));
const far=!recoveringPeer&&a.world!.groupId&&a.world!.groupNames.length>1&&a.world!.groupNames.filter(n=>n!==a.name).every(n=>!a.world!.nearby.includes(n));
if(c.partiesEnabled&&far){
await db.prepare('INSERT OR IGNORE INTO community_clock VALUES (?,?)').bind('apart:'+a.id,now).run();
const apart=await db.prepare('SELECT at FROM community_clock WHERE id=?').bind('apart:'+a.id).first<{at:number}>();
+8 -3
View File
@@ -36,9 +36,14 @@ and only when fresh and at their class starting level.
5. Recreate only the world container with `docker compose up -d --no-deps ac-worldserver`.
6. Restart the ALE relay to load priority delivery for permanent-death events.
The guard is enabled by installing its Lua file, independently of the dashboard
hardcore toggle. Remove the guard and recreate the world before intentionally
switching back to non-hardcore play. Do not erase the registry to recycle deaths.
The installed guard reads `acore_auth.azerothai_realm_rules.hardcore`, synchronized
from the dashboard setting by the operations agent (normally within one minute).
When disabled, future deaths use the native playerbot resurrection behavior:
identities, memories, gear, and party membership survive. Dead active residents
still occupy their faction slots, so a recoverable death cannot spawn a replacement
or bypass the 50-bot cap. Historical permanent retirements remain retired.
For an immediate mode switch, update the dashboard first and synchronize the
native rule before declaring the transition complete. Do not erase the registry.
## Verification and recovery
+17
View File
@@ -55,3 +55,20 @@ before any model call. Model instructions explicitly prioritize survival over
quests, loot and social goals. Stuck recovery yields to the native survival hold.
Mortality improvement must be measured over subsequent real playtime; these rules
do not make bots invulnerable or resurrect dead characters.
## Persistent companions and failed-encounter avoidance
New party invitations now require a level gap of at most five. Followers regroup
at eight yards; healthy leaders pause when a nearby companion is injured or more
than fifteen yards away (the existing bounded wait still prevents permanent idle).
A recently observed dead party member in normal resurrection mode does not trigger
the runtime's separation-based party departure. Human and unmanaged groups remain
protected.
Overpowered enemies, extra attackers, and rapid health loss mark that selected
enemy for ten minutes of avoidance. The guard stops renewed attacks and asks the
native AI to flee if that hostile target is selected again within range. This
memory is local to the running Lua process, expires, and is not a pathfinding
barrier or a guarantee that the bot can escape. No automatic gear, talent, or level
boosts are applied.
+4
View File
@@ -44,6 +44,10 @@ try:
state=json.load(urllib.request.urlopen(base+'/api/state',timeout=10));days=max(3,min(30,int(state.get('opsRetentionDays',7))))
env=os.environ.copy();env['MYSQL_PWD']=cfg['DOCKER_DB_ROOT_PASSWORD']
def sql(q):return subprocess.check_output(['docker','exec','-e','MYSQL_PWD','ac-database','mysql','-uroot','-N','-B','-e',q],env=env,text=True)
# Share the selected mode with the native guard; retain historical retirements.
sql("CREATE TABLE IF NOT EXISTS acore_auth.azerothai_realm_rules (id TINYINT PRIMARY KEY,hardcore TINYINT NOT NULL)")
hardcore=1 if state.get('settings',{}).get('hardcore',{}).get('enabled',True) else 0
sql(f"INSERT INTO acore_auth.azerothai_realm_rules VALUES (1,{hardcore}) ON DUPLICATE KEY UPDATE hardcore=VALUES(hardcore)")
# Reconcile delayed verified deaths by the full native identity. This never
# revives an identity, changes a protected resident, or counts a human as a bot.
if state.get('settings',{}).get('hardcore',{}).get('enabled'):
+6
View File
@@ -48,3 +48,9 @@ test('companion ranking prefers complementary native roles and shared active que
const {companionScore}=require('../.test-build/community.js');const a=bot('A'),b=bot('B'),c=bot('C');a.world.combatRole='tank';b.world.combatRole='healer';c.world.combatRole='damage';assert.ok(companionScore(a,b)>companionScore(a,c));
b.world.combatRole='damage';a.world.quests=[1,2];c.world.quests=[1,2];assert.ok(companionScore(a,c)>companionScore(a,b));
});
test('adventuring companions stay within five levels',()=>{
const a=bot('A'),b=bot('B');b.level=a.level+6;
assert.equal(partyDecision(a,b).accepted,false);
b.level=a.level+5;assert.equal(partyDecision(a,b).accepted,true);
});
+12
View File
@@ -92,3 +92,15 @@ test('unverified bridge death fails safe without erasing identity',async()=>{
await applyAuthoritativeAleEvent(store,'player.snapshot',{roster:true,observedAt:100,characterGuids:[123]});
assert.equal(store.state.bots.filter(b=>b.status==='online').length,0);
});
test('normal resurrection mode preserves memories and identity across death and later snapshot',async()=>{
const store=new Store();store.state.settings.hardcore.enabled=false;
const bot=store.state.bots.find(b=>b.id==='bot-6');
bot.memories.push({id:'keep',text:'My companion',importance:8,source:'observation',at:new Date().toISOString()});
const payload={name:bot.name,isBot:true,characterGuid:99,accountId:2,healthPct:0};
await applyAuthoritativeAleEvent(store,'player.killed-by-creature',payload);
assert.notEqual(store.state.bots.find(b=>b.id===bot.id).status,'dead');
await applyAuthoritativeAleEvent(store,'player.snapshot',{...payload,healthPct:100});
const revived=store.state.bots.find(b=>b.id===bot.id);
assert.equal(revived.death,undefined);assert.ok(revived.memories.some(m=>m.id==='keep'));
});
+8
View File
@@ -20,10 +20,12 @@ function player(guid,name,account,level,dead)
players[name]=p; return p
end
function AuthDBQuery(sql)
if string.find(sql,"azerothai_realm_rules",1,true) then return {GetUInt32=function()return mode or 1 end} end
if string.find(sql,'SELECT id FROM account',1,true) then
if string.find(sql,'id=99 ',1,true) then return nil end
return {GetUInt32=function() return 2 end}
end
if string.find(sql,"SELECT guid,name,account_id FROM azerothai_lifecycle WHERE state='dead'",1,true) then return nil end
local q={i=1}
function q:GetUInt32(n) return rows[self.i][n+1] end
function q:GetString(n) return rows[self.i][n+1] end
@@ -131,3 +133,9 @@ assert(entries==2,'Death Knights must be audited at level 55')
events[8](8,nil,players.HordeDK);tick();assert(not players.HordeDK and deaths==1)
""")
print('PASS: retirement, protection, level-1 entry, replay safety, and 25/25 faction balancing')
normal=LuaRuntime();normal.execute(setup)
normal.execute("mode=0;players={};rows={};for i=1,25 do rows[#rows+1]={100+i,'Resident'..i,2,0,'active',2};player(100+i,'Resident'..i,2,5,i==1) end;rows[#rows+1]={200,'Reserve',2,0,'reserve',2};player(200,'Reserve',2,1,false)")
normal.execute((Path(__file__).parents[1]/'ale/azerothai_lifecycle.lua').read_text())
normal.execute("events[8](8,nil,players.Resident1);tick();assert(players.Resident1 and deaths==0);assert(not players.Reserve,'Recoverable death must retain its population slot');local n=0;for _ in pairs(players)do n=n+1 end;assert(n==25)")
print('Normal resurrection mode preserves identity and reserves the dead resident population slot')
+4 -2
View File
@@ -44,9 +44,9 @@ l=new();l.execute("group();AzerothAISetSurvivalEnabled(false);step(0);assert(#ca
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.combat=true;A.hp=100;A.enemy={GetGUID=function()return 999 end,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.friendly=true;A.selected={GetGUID=function()return 999 end,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);local found=false;for _,q in pairs(auditRows)do if string.find(q,'6F75742D6F662D636F6D626174') then found=true end end;assert(found)")
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)")
@@ -59,3 +59,5 @@ l=new();l.execute("A.combat=true;A.hp=75;A.attackers={1,2};step(0);assert(calls[
l=new();l.execute("A.combat=true;step(0);A.hp=75;step(2);assert(calls[1]=='flee')")
l=new();l.execute("A.hp=80;step(0);A.hp=100;local query=AuthDBQuery;function AuthDBQuery(sql)if string.find(sql,'survival_holds WHERE guid=1',1,true) then return {GetUInt32=function(_,i)return ({[0]=1,[2]=11,[3]=1,[4]=0,[5]=0,[6]=0,[7]=0})[i] end,GetString=function()return 'A' end} end;return query(sql)end;step(30);assert(A.strategies.rpg==true);assert(not AzerothAISurvivalOverride(A))")
print('Readiness holds, mana recovery, outnumbered/rapid-loss retreat, and original-strategy restoration passed')
l=new();l.execute("A.combat=true;A.hp=75;A.attackers={1,2};A.enemy={GetGUID=function()return 999 end,GetLevel=function()return 5 end,ToCreature=function()return nil end};step(0);A.selected=A.enemy;A.combat=false;A.hp=100;A.attackers={};step(20);assert(A.attackStopped and calls[#calls]=='flee');A.attackStopped=false;step(601);assert(not A.attackStopped)")
print('Dangerous encounter target avoidance expires after ten minutes')