Support persistent residents with normal resurrection and closer companions
Core tests / core (push) Successful in 2m54s
Core tests / core (push) Successful in 2m54s
This commit is contained in:
@@ -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()))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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}>();
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user