Enforce hardcore retirement with level-one faction-balanced replacements

This commit is contained in:
Codex
2026-09-22 15:55:41 -04:00
parent bc50395221
commit 77f6b56c2f
10 changed files with 526 additions and 5 deletions
+13 -2
View File
@@ -85,6 +85,11 @@ local function emit(kind, payload)
AuthDBExecute(sql)
end
local function area_name(id)
local ok,name=pcall(GetAreaName,id)
return ok and name or tostring(id)
end
local function player_payload(player)
if not player then return {} end
local isBot = is_playerbot(player)
@@ -96,7 +101,7 @@ local function player_payload(player)
mapId=player:GetMapId(),
zoneId=player:GetZoneId(),
areaId=player:GetAreaId(),
zoneName=GetAreaName(player:GetZoneId()) or tostring(player:GetZoneId()),
zoneName=area_name(player:GetZoneId()),
race=player:GetRace(),
class=player:GetClass(),
raceName=player:GetRaceAsString() or tostring(player:GetRace()),
@@ -136,6 +141,12 @@ function AzerothAIEmitPlayerSnapshot(name)
return 1
end
function AzerothAIEmitPermanentDeath(player)
local payload=player_payload(player)
payload.deathId="hardcore-"..tostring(player:GetGUIDLow())
emit("player.permanent-death",payload)
end
local function mark_consumed(id)
AuthDBExecute("UPDATE azerothai_command_inbox SET consumed_at=NOW() WHERE id=" .. db_text(to_hex(id)))
end
@@ -254,7 +265,7 @@ RegisterPlayerEvent(27, function(event, player, newZone, newArea)
local payload=player_payload(player)
payload.zone=newZone
payload.area=newArea
payload.zoneName=GetAreaName(newZone) or tostring(newZone)
payload.zoneName=area_name(newZone)
emit("player.zone",payload)
end)
+190
View File
@@ -0,0 +1,190 @@
-- Persistent hardcore retirement. The native random-bot manager fills vacancies
-- from its factory-created, unused level-1 reserve; identities are never reset.
-- Install only after deploy/playable/install-lifecycle.py has seeded the registry.
local residents = {}
local ready = false
local vetoAt = {}
local unmanaged = {}
local TARGET_PER_FACTION = 25
local gates = {}
local gateAt = {}
local arrivals = {}
local alliance = {[1]=true,[3]=true,[4]=true,[7]=true,[11]=true}
local horde = {[2]=true,[5]=true,[6]=true,[8]=true,[10]=true}
local function faction(row)
if alliance[row.race] then return 1 end
if horde[row.race] then return 2 end
return nil
end
local function sqltext(value)
return "CONVERT(UNHEX('"..(string.gsub(value,".",function(c) return string.format("%02X",string.byte(c)) end)).."') USING utf8mb4)"
end
local function enroll(player)
local guid=player:GetGUIDLow()
if unmanaged[guid] then return nil end
local account=player:GetAccountId()
local name=player:GetName()
if string.lower(name)=="zaku" then return nil end
-- The native factory can create additional accounts during startup. Accept
-- only its accounts and only genuinely unused characters as new residents.
local owned=AuthDBQuery("SELECT id FROM account WHERE id="..account.." AND username REGEXP '^RNDBOT[0-9]+$'")
if not owned then unmanaged[guid]=true;return nil end
local character=CharDBQuery("SELECT totaltime FROM characters WHERE guid="..guid.." AND account="..account)
if not character then return nil end
local state=(player:GetLevel()==1 and character:GetUInt32(0)<=60) and "reserve" or "blocked"
local row={name=name,account=account,protected=false,state=state,race=player:GetRace()}
residents[guid]=row
AuthDBExecute("INSERT IGNORE INTO azerothai_lifecycle(guid,name,account_id,protected,state,race) VALUES ("..
guid..","..sqltext(name)..","..account..",0,"..sqltext(state)..","..row.race..")")
return row
end
local function reload()
local q=AuthDBQuery("SELECT guid,name,account_id,protected,state,race FROM azerothai_lifecycle")
if not q then error("AzerothAI lifecycle registry is unavailable or empty") end
local nextResidents={}
repeat
nextResidents[q:GetUInt32(0)]={name=q:GetString(1),account=q:GetUInt32(2),
protected=q:GetUInt32(3)==1,state=q:GetString(4),race=q:GetUInt32(5)}
until not q:NextRow()
residents=nextResidents
ready=true
end
local function identity(player)
if not ready or not player then return nil end
local guid=player:GetGUIDLow()
local row=residents[guid] or enroll(player)
if not row or row.account~=player:GetAccountId() or row.name~=player:GetName() then return nil end
if row.protected or string.lower(row.name)=="zaku" then return nil end
return row,guid
end
local function recordDeath(player)
local row,guid=identity(player)
if not row or row.state=="dead" or row.state=="blocked" then return end
row.state="dead"
AuthDBExecute("UPDATE azerothai_lifecycle SET state='dead',died_at=COALESCE(died_at,NOW()) WHERE guid="..
guid.." AND protected=0 AND LOWER(name)<>'zaku'")
if AzerothAIEmitPermanentDeath then AzerothAIEmitPermanentDeath(player) end
end
local function enforce()
if not ready then reload() end
local mgr=GetRandomPlayerbotMgr()
-- Disallow every retired identity before the native manager fills vacancies.
for guid,row in pairs(residents) do
if (row.state=="dead" or row.state=="blocked") and not row.protected and string.lower(row.name)~="zaku" then
local player=GetPlayerByName(row.name)
local removed=false
if player and player:GetGUIDLow()==guid and player:GetAccountId()==row.account then
mgr:Remove(player)
removed=true
end
-- Remove clears the manager's event cache, so set the login veto afterward.
if removed or not vetoAt[guid] or os.time()-vetoAt[guid]>300 then
mgr:SetValue(guid,"add",0)
mgr:SetValue(guid,"logout",1,"AzerothAI permanent retirement")
vetoAt[guid]=os.time()
end
end
end
local players=GetPlayersInWorld() or {}
local counts={0,0}
local online={}
local returning={}
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
returning[#returning+1]={player=player,row=row,guid=guid}
end
end
table.sort(returning,function(a,b)
local ap=a.row.protected or string.lower(a.row.name)=="zaku"
local bp=b.row.protected or string.lower(b.row.name)=="zaku"
if ap~=bp then return ap end
return a.guid<b.guid
end)
for _,item in ipairs(returning) do
local team=faction(item.row)
if team then
if counts[team]<TARGET_PER_FACTION or item.row.protected or string.lower(item.row.name)=="zaku" then
counts[team]=counts[team]+1
online[item.guid]=true
else
-- Returning residents keep their identity and progress while held offline.
mgr:Remove(item.player)
mgr:SetValue(item.guid,"logout",1,"AzerothAI faction capacity")
gates[item.guid]=false
gateAt[item.guid]=os.time()
end
end
end
players=GetPlayersInWorld() or {}
for _,player in pairs(players) do
local row,guid=identity(player)
if row and row.state~="dead" and row.state~="blocked" then
if player:IsDead() then
recordDeath(player)
elseif row.state=="reserve" then
if (arrivals[guid] or player:GetLevel())~=1 then
row.state="blocked"
AuthDBExecute("UPDATE azerothai_lifecycle SET state='blocked' WHERE guid="..guid.." AND state='reserve'")
PrintError("[AzerothAI] Replacement did not start at level 1: "..row.name)
elseif faction(row) and counts[faction(row)]<TARGET_PER_FACTION then
counts[faction(row)]=counts[faction(row)]+1
row.state="active"
online[guid]=true
AuthDBExecute("UPDATE azerothai_lifecycle SET state='active',entry_level=1,activated_at=COALESCE(activated_at,NOW()) WHERE guid="..guid.." AND state='reserve'")
if AzerothAIEmitPlayerSnapshot then AzerothAIEmitPlayerSnapshot(row.name) end
PrintInfo("[AzerothAI] New level-1 resident entered the world: "..row.name)
else
-- A login already in flight must not push its faction over the target.
mgr:Remove(player)
mgr:SetValue(guid,"logout",1,"AzerothAI faction capacity")
gates[guid]=false
gateAt[guid]=os.time()
end
end
end
end
-- Open only enough fresh identities to fill each faction's actual deficit.
-- Persisted login vetoes keep the native random selector inside these slots.
local slots={math.max(0,TARGET_PER_FACTION-counts[1]),math.max(0,TARGET_PER_FACTION-counts[2])}
local candidates={}
for guid,row in pairs(residents) do if (row.state=="reserve" or row.state=="active") and not online[guid] and not row.protected and string.lower(row.name)~="zaku" then candidates[#candidates+1]=guid end end
table.sort(candidates,function(a,b)
if residents[a].state~=residents[b].state then return residents[a].state=="active" end
return a<b
end)
for _,guid in ipairs(candidates) do
local team=faction(residents[guid])
local allowed=team~=nil and slots[team]>0
if allowed then slots[team]=slots[team]-1 end
if gates[guid]~=allowed or not gateAt[guid] or os.time()-gateAt[guid]>300 then
mgr:SetValue(guid,"logout",allowed and 0 or 1,"AzerothAI faction capacity")
gates[guid]=allowed
gateAt[guid]=os.time()
end
if not allowed and mgr:GetValue(guid,"add")~=0 then mgr:SetValue(guid,"add",0) end
end
end
-- Record death synchronously in memory; logout happens outside the death hook.
RegisterPlayerEvent(8,function(event,killer,killed) recordDeath(killed) end)
RegisterPlayerEvent(6,function(event,killer,killed) recordDeath(killed) end)
RegisterPlayerEvent(3,function(event,player)
local row,guid=identity(player)
if row and row.state=="reserve" then arrivals[guid]=player:GetLevel() end
end)
CreateLuaEvent(function()
local ok,err=pcall(enforce)
if not ok then PrintError("[AzerothAI] Lifecycle enforcement failed: "..tostring(err)) end
end,1000,0)
reload()
enforce()
PrintInfo("[AzerothAI] Persistent hardcore retirement and reserve replacement enabled")