diff --git a/ale/azerothai_lifecycle.lua b/ale/azerothai_lifecycle.lua index e4d303e..eb9962a 100644 --- a/ale/azerothai_lifecycle.lua +++ b/ale/azerothai_lifecycle.lua @@ -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())) diff --git a/ale/azerothai_parties.lua b/ale/azerothai_parties.lua index 5e9516d..1a798a9 100644 --- a/ale/azerothai_parties.lua +++ b/ale/azerothai_parties.lua @@ -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() diff --git a/ale/azerothai_survival.lua b/ale/azerothai_survival.lua index 05539b0..94ef1b2 100644 --- a/ale/azerothai_survival.lua +++ b/ale/azerothai_survival.lua @@ -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 diff --git a/core/community.ts b/core/community.ts index f4b13c7..8d4ba57 100644 --- a/core/community.ts +++ b/core/community.ts @@ -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}>(); diff --git a/docs/hardcore-replacement.md b/docs/hardcore-replacement.md index 24a3983..209b1c7 100644 --- a/docs/hardcore-replacement.md +++ b/docs/hardcore-replacement.md @@ -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 diff --git a/docs/survival-cooperation.md b/docs/survival-cooperation.md index fa9cf12..01e2430 100644 --- a/docs/survival-cooperation.md +++ b/docs/survival-cooperation.md @@ -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. diff --git a/scripts/operations-agent.py b/scripts/operations-agent.py index eb1062f..5714186 100644 --- a/scripts/operations-agent.py +++ b/scripts/operations-agent.py @@ -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'): diff --git a/tests/community.test.cjs b/tests/community.test.cjs index fca5737..47e59d6 100644 --- a/tests/community.test.cjs +++ b/tests/community.test.cjs @@ -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); +}); diff --git a/tests/hardcore.test.cjs b/tests/hardcore.test.cjs index 7b4ff78..c920804 100644 --- a/tests/hardcore.test.cjs +++ b/tests/hardcore.test.cjs @@ -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')); +}); diff --git a/tests/lifecycle-lua.test.py b/tests/lifecycle-lua.test.py index 3c3af29..7071a7c 100644 --- a/tests/lifecycle-lua.test.py +++ b/tests/lifecycle-lua.test.py @@ -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') diff --git a/tests/survival-lua.test.py b/tests/survival-lua.test.py index 430ad76..e6d98ff 100644 --- a/tests/survival-lua.test.py +++ b/tests/survival-lua.test.py @@ -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')