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")
+1
View File
@@ -25,6 +25,7 @@ const STATEFUL_EVENTS=new Set([
'player.achievement',
'player.close-call',
'player.killed-by-creature',
'player.permanent-death',
'player.repop',
'player.resurrect'
]);
+1 -1
View File
@@ -41,7 +41,7 @@ export default function Home(){
<div className="page-heading"><div><div className="eyebrow">{page==='GM Console'?'THE WORLD IS YOUR SANDBOX':'AZEROTHAI CONTROL PLANE'}</div><h1>{page==='Dashboard'?'Your realm, alive.':page}</h1><p className="subtitle">{({'Dashboard':'The people, personalities, and stories that make your world.','World Monitor':'Watch Azeroth breathe: population, activity, systems, and the lives unfolding right now.','Realm Director':'Shape the adventure around you. Let your characters bring it to life.','World & Housing':'Homes, routines, simulation bands, and believable population flow.','Bots':'Give every character a purpose, then let their story unfold.','Bot Recipes':'Same model. Different personalities. A world of possibilities.','Memory & Bonds':'A shared history makes the next encounter matter.','Event Stream':'Follow the decisions and changes happening across your realm.','GM Console':'Pull up a chair. Put on the blue robe. Run your world.','Server & Models':'Connect the game engine and the minds behind your characters.'} as any)[page]}</p></div><div className="actions">{page==='Bot Recipes'?<Button onClick={()=>{setRecipe(null);setRecipeOpen(true)}}><Plus size={16}/>Create recipe</Button>:<><Button variant="outline" disabled={busy} onClick={()=>void refresh()}><RefreshCw size={15}/>Refresh</Button><Button onClick={()=>setCreateOpen(true)}><Plus size={16}/>Create bot</Button></>}</div></div>
{error&&<div className="notice" role="alert">{error} <Button variant="outline" onClick={()=>void refresh()}>Retry</Button></div>}
{!state?<div className="stats">{[1,2,3,4].map(n=><Skeleton key={n} className="h-32"/>)}</div>:<>
{page==='Dashboard'&&<><div className="stats">{[[Users,'Active bots',state.bots.filter(b=>b.status==='online').length,'Online in the simulation'],[BookOpen,'Bot recipes',state.recipes.length,'Distinct ways to experience the world'],[Brain,'Memories',state.bots.reduce((n,b)=>n+b.memories.length,0),'Stories carried into future decisions'],[Network,'Relationships',state.bots.reduce((n,b)=>n+b.relationships.length,0),'Directed bonds between characters']].map(([Icon,label,value,note]:any)=><div className="panel stat" key={label}><div className="stat-label">{label}<Icon/></div><div className="stat-value">{value}</div><small>{label==='Active bots'&&state.settings.connector!=='simulation'?'Acknowledged by the game bridge':note}</small></div>)}</div><section className="panel director-strip"><div className="director-content"><Compass className="director-icon"/><div><div className="eyebrow">YOUR NEXT ADVENTURE</div><h2>{state.director.experience}</h2><p>{state.director.zone} · {state.director.brief}</p></div></div><Button variant="outline" onClick={()=>nav('Realm Director')}>Shape the experience <ArrowRight size={15}/></Button></section><div className="dashboard-grid"><Panel title="Your characters" icon={Users} action={<Button variant="ghost" size="sm" onClick={()=>nav('Bots')}>Manage bots <ArrowRight size={14}/></Button>}>{botTable(state.bots.slice(0,5))}</Panel><Panel title="Realm pulse" icon={Radio}><div className="panel-body">{Array.from(new Set(state.bots.filter(b=>b.status==='online').map(b=>b.zone))).map(zone=>{const n=state.bots.filter(b=>b.status==='online'&&b.zone===zone).length;return <div className="zone-row" key={zone}><span className="zone-label">{zone}</span><span className="bar"><i style={{width:`${n/Math.max(1,state.bots.filter(b=>b.status==='online').length)*100}%`}}/></span><span>{n}</span></div>})}<p className="fineprint">Bot locations from the selected connector. Simulation locations are illustrative.</p><div className="engine-box" style={{marginTop:25,marginBottom:0}}><Brain size={23}/><div><h3>{state.settings.provider==='deterministic'?'Personality simulator':state.settings.model}</h3><p>{state.settings.provider==='deterministic'?'No model required. Try a decision from any online bot.':'Local model selected. Each decision includes identity and memories.'}</p></div></div></div></Panel><Panel title="Recent activity" icon={Activity} action={<Button variant="ghost" size="sm" onClick={()=>nav('Event Stream')}>View stream <ArrowRight size={14}/></Button>}>{<Events events={state.events.slice(-5)}/>}</Panel><Panel title="The Game Master’s chair" icon={Shield}><div className="panel-body"><p className="list-muted">Set the scene. Announce a gathering. Move a character to the next chapter of their story.</p><div className="gm-preview"><span>&lt;GM&gt; AzerothAI</span><p>Welcome to your world.</p><code>.announce An adventure awaits...</code></div><Button variant="outline" onClick={()=>nav('GM Console')}>Open GM console <Terminal size={15}/></Button></div></Panel></div></>}
{page==='Dashboard'&&<><div className="stats">{[[Users,'Active bots',state.bots.filter(b=>b.status==='online'&&(state.settings.connector==='simulation'||b.autonomy?.managed)).length,'Online in the simulation'],[BookOpen,'Bot recipes',state.recipes.length,'Distinct ways to experience the world'],[Brain,'Memories',state.bots.reduce((n,b)=>n+b.memories.length,0),'Stories carried into future decisions'],[Network,'Relationships',state.bots.reduce((n,b)=>n+b.relationships.length,0),'Directed bonds between characters']].map(([Icon,label,value,note]:any)=><div className="panel stat" key={label}><div className="stat-label">{label}<Icon/></div><div className="stat-value">{value}</div><small>{label==='Active bots'&&state.settings.connector!=='simulation'?'Acknowledged by the game bridge':note}</small></div>)}</div><section className="panel director-strip"><div className="director-content"><Compass className="director-icon"/><div><div className="eyebrow">YOUR NEXT ADVENTURE</div><h2>{state.director.experience}</h2><p>{state.director.zone} · {state.director.brief}</p></div></div><Button variant="outline" onClick={()=>nav('Realm Director')}>Shape the experience <ArrowRight size={15}/></Button></section><div className="dashboard-grid"><Panel title="Your characters" icon={Users} action={<Button variant="ghost" size="sm" onClick={()=>nav('Bots')}>Manage bots <ArrowRight size={14}/></Button>}>{botTable(state.bots.slice(0,5))}</Panel><Panel title="Realm pulse" icon={Radio}><div className="panel-body">{Array.from(new Set(state.bots.filter(b=>b.status==='online').map(b=>b.zone))).map(zone=>{const n=state.bots.filter(b=>b.status==='online'&&b.zone===zone).length;return <div className="zone-row" key={zone}><span className="zone-label">{zone}</span><span className="bar"><i style={{width:`${n/Math.max(1,state.bots.filter(b=>b.status==='online').length)*100}%`}}/></span><span>{n}</span></div>})}<p className="fineprint">Bot locations from the selected connector. Simulation locations are illustrative.</p><div className="engine-box" style={{marginTop:25,marginBottom:0}}><Brain size={23}/><div><h3>{state.settings.provider==='deterministic'?'Personality simulator':state.settings.model}</h3><p>{state.settings.provider==='deterministic'?'No model required. Try a decision from any online bot.':'Local model selected. Each decision includes identity and memories.'}</p></div></div></div></Panel><Panel title="Recent activity" icon={Activity} action={<Button variant="ghost" size="sm" onClick={()=>nav('Event Stream')}>View stream <ArrowRight size={14}/></Button>}>{<Events events={state.events.slice(-5)}/>}</Panel><Panel title="The Game Master’s chair" icon={Shield}><div className="panel-body"><p className="list-muted">Set the scene. Announce a gathering. Move a character to the next chapter of their story.</p><div className="gm-preview"><span>&lt;GM&gt; AzerothAI</span><p>Welcome to your world.</p><code>.announce An adventure awaits...</code></div><Button variant="outline" onClick={()=>nav('GM Console')}>Open GM console <Terminal size={15}/></Button></div></Panel></div></>}
{page==='World Monitor'&&<WorldMonitor state={state} monitor={monitor} capabilities={capabilities} openBot={setBotId}/>}
{page==='Bots'&&<><div className="toolbar"><Input aria-label="Search bots" placeholder="Search name, class, or location…" value={query} onChange={e=>setQuery(e.target.value)}/><Choice label="Bot status" value={filter} onChange={setFilter} items={options(['all','online','paused','offline','retired'])}/><small>{visible.length} characters</small></div><Panel title="Bot lifecycle" icon={Users}>{visible.length?botTable(visible):<div className="empty-note">No matching bots. Create a character or adjust your filters.</div>}</Panel></>}
{page==='Bot Recipes'&&<><div className="notice">Recipes are versioned. Saving a recipe preserves existing bots’ personalities until you explicitly apply the new version.</div><div className="recipe-grid">{state.recipes.map((r,i)=><button key={r.id} className="panel recipe-card" onClick={()=>{setRecipe(r);setRecipeOpen(true)}}><div style={{display:'flex',justifyContent:'space-between'}}><span className="recipe-symbol">{i%2?<Compass size={23}/>:<BookOpen size={23}/>}</span><small>VERSION {r.version}</small></div><h3>{r.name}</h3><p>{r.description}</p><div className="tags">{r.tags.map(t=><span className="tag" key={t}>{t}</span>)}</div><div style={{display:'flex',justifyContent:'space-between',marginTop:21}}><small>{state.bots.filter(b=>b.recipeId===r.id).length} bot identities</small><small style={{color:'#9abef7'}}>Edit recipe →</small></div></button>)}</div></>}
+1 -1
View File
@@ -179,7 +179,7 @@ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,pa
b.memories=b.memories.slice(-100);
}
event(s,'memory',`${b.name} survived a close call in ${b.zone}; the experience was committed to memory.`,b.id);
}else if(kind==='player.killed-by-creature'){
}else if(kind==='player.killed-by-creature'||kind==='player.permanent-death'){
if(b.plotArmorHpFloor>=1){
event(s,'error',`Plot armor breach: authoritative ALE reported ${b.name} dead. Worldserver damage enforcement must prevent this transition.`,b.id);
}else if(s.settings.hardcore.enabled){
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Back up the realm, seed its lifecycle registry, and install the ALE guard.
Run as the Docker-authorized realm owner, with --apply after reviewing the default read-only plan.
The world server must be stopped for --apply. This script never deletes characters.
"""
import argparse
import datetime
import gzip
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import urllib.request
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--lab', type=Path, required=True)
parser.add_argument('--apply', action='store_true')
args = parser.parse_args()
repo = Path(__file__).resolve().parents[2]
lab = args.lab.resolve()
config = {}
for line in (lab / 'runtime.env').read_text().splitlines():
if '=' in line and not line.startswith('#'):
key, value = line.split('=', 1)
config[key] = value.strip().strip('"').strip("'")
env = os.environ.copy()
env['MYSQL_PWD'] = config['DOCKER_DB_ROOT_PASSWORD']
def mysql(sql):
return subprocess.check_output(['docker', 'exec', '-e', 'MYSQL_PWD',
'ac-database', 'mysql', '-uroot', '-N', '-B', '-e', sql], env=env, text=True)
state = json.load(urllib.request.urlopen('http://127.0.0.1:8787/api/state', timeout=10))
assert state['settings']['hardcore']['enabled'], 'Enable hardcore before installing the guard'
raw = mysql("SELECT c.guid,c.name,c.account,c.level,c.totaltime,c.class FROM acore_characters.characters c "
"JOIN acore_auth.account a ON a.id=c.account WHERE a.username REGEXP '^RNDBOT[0-9]+$'")
characters = [line.split('\t') for line in raw.splitlines() if line]
managed = {b.get('autonomy', {}).get('characterGuid'): b for b in state['bots']
if b.get('autonomy', {}).get('managed')}
rows = []
counts = {}
for guid, name, account, level, played, cls in characters:
guid, account = int(guid), int(account)
bot = managed.get(guid)
if bot:
assert bot['name'] == name and bot['autonomy']['accountId'] == account, 'Identity mismatch'
protected = name.lower() == 'zaku' or bool(bot and bot.get('plotArmorHpFloor', 0) > 0)
if protected:
status = 'active'
elif bot and bot['status'] == 'dead':
assert bot.get('death', {}).get('verified'), 'Unverified death cannot retire a character'
status = 'dead'
elif bot and bot['status'] != 'retired':
status = 'active'
elif int(level) == 1 and int(played) == 0 and int(cls) != 6:
status = 'reserve'
else:
status = 'blocked'
counts[status] = counts.get(status, 0) + 1
rows.append((guid, name, account, int(protected), status))
assert any(name.lower() == 'zaku' and protected for _, name, _, protected, _ in rows)
assert counts.get('reserve', 0) >= counts.get('dead', 0), 'Insufficient unused level-1 reserve'
print('Lifecycle plan:', json.dumps(counts), flush=True)
if not args.apply:
raise SystemExit(0)
running = json.loads(subprocess.check_output(['docker', 'inspect', 'ac-worldserver']))[0]['State']['Running']
assert not running, 'Stop ac-worldserver before applying this migration'
os.umask(0o077)
backup = lab / ('lifecycle-backup-' + datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ'))
backup.mkdir()
(backup / 'realm-state.json').write_text(json.dumps(state))
(backup / 'registry-plan.json').write_text(json.dumps(rows))
dump = subprocess.Popen(['docker', 'exec', '-e', 'MYSQL_PWD', 'ac-database',
'mysqldump', '-uroot', '--single-transaction', '--no-tablespaces', '--set-gtid-purged=OFF',
'--databases', 'acore_auth', 'acore_characters', 'acore_playerbots'], env=env, stdout=subprocess.PIPE)
with gzip.open(backup / 'realm.sql.gz', 'wb') as stream:
shutil.copyfileobj(dump.stdout, stream)
assert dump.wait() == 0, 'Database backup failed; no migration applied'
core = lab / 'azerothcore'
override = core / 'docker-compose.override.yml'
shutil.copy2(override, backup / override.name)
shutil.copytree(core / 'azerothai-lua', backup / 'azerothai-lua')
mysql('''CREATE TABLE IF NOT EXISTS acore_auth.azerothai_lifecycle (
guid INT UNSIGNED PRIMARY KEY, name VARCHAR(64) NOT NULL,
account_id INT UNSIGNED NOT NULL, protected TINYINT NOT NULL DEFAULT 0,
state VARCHAR(16) NOT NULL, died_at TIMESTAMP NULL, activated_at TIMESTAMP NULL,
race TINYINT UNSIGNED NOT NULL DEFAULT 0, entry_level TINYINT UNSIGNED NULL
) ENGINE=InnoDB''')
for column, definition in [('race', 'TINYINT UNSIGNED NOT NULL DEFAULT 0'), ('entry_level', 'TINYINT UNSIGNED NULL')]:
if not mysql(f"SHOW COLUMNS FROM acore_auth.azerothai_lifecycle LIKE '{column}'").strip():
mysql(f'ALTER TABLE acore_auth.azerothai_lifecycle ADD COLUMN {column} {definition}')
def text(value):
return "CONVERT(UNHEX('" + value.encode().hex() + "') USING utf8mb4)"
# Never overwrite existing ledger history on a repeated installation.
values = [f"({guid},{text(name)},{account},{protected},{text(status)},"
+ ("NOW())" if status == 'dead' else "NULL)")
for guid, name, account, protected, status in rows]
mysql("INSERT IGNORE INTO acore_auth.azerothai_lifecycle "
"(guid,name,account_id,protected,state,died_at) VALUES " + ','.join(values))
mysql("UPDATE acore_auth.azerothai_lifecycle l JOIN acore_characters.characters c "
"ON c.guid=l.guid AND c.account=l.account_id AND c.name=l.name SET l.race=c.race")
# The native factory separates manual AddClass accounts from autonomous accounts.
# Make the verified unused reserve selectable without increasing the online cap.
mysql("UPDATE acore_playerbots.playerbots_account_type t "
"JOIN acore_auth.azerothai_lifecycle l ON l.account_id=t.account_id "
"SET t.account_type=1,t.assignment_date=NOW() WHERE l.state='reserve'")
source = (repo / 'ale/azerothai_bridge.lua').read_text()
current = (core / 'azerothai-lua/azerothai_bridge.lua').read_text()
for key in ['POLL_MS', 'PROTECTED', 'KNOWN_BOT_ACCOUNTS']:
pattern = r'^local ' + key + r' = .*$'
line = re.search(pattern, current, re.M)
assert line, key
source, n = re.subn(pattern, lambda match: line.group(0), source, flags=re.M)
assert n == 1, key
(core / 'azerothai-lua/azerothai_bridge.lua').write_text(source)
shutil.copy2(repo / 'ale/azerothai_lifecycle.lua', core / 'azerothai-lua/azerothai_lifecycle.lua')
settings = {'AC_AI_PLAYERBOT_DISABLE_RANDOM_LEVELS': '1',
'AC_AI_PLAYERBOT_RANDOMBOT_STARTING_LEVEL': '1',
'AC_AI_PLAYERBOT_DISABLE_DEATH_KNIGHT_LOGIN': '1'}
content = override.read_text()
for key, value in settings.items():
pattern = r'(?m)^(\s*)' + key + r':.*$'
if re.search(pattern, content):
content = re.sub(pattern, lambda m: m.group(1) + key + ': "' + value + '"', content)
else:
assert ' environment:\n' in content, 'Unexpected compose environment layout'
content = content.replace(' environment:\n', ' environment:\n ' + key + ': "' + value + '"\n', 1)
override.write_text(content)
print('Migration installed. Backup:', backup, flush=True)
print('Recreate ac-worldserver to activate; verify the lifecycle startup line and population counts.', flush=True)
+56
View File
@@ -0,0 +1,56 @@
# Hardcore population replacement
The game-side ALE lifecycle guard closes an identity permanently after death.
It preserves character data and the AzerothAI memorial, removes the character
from the random-bot manager, and refreshes a persistent login veto. Zaku and
protected identities are excluded. GUID, account and name must all match the
installation registry; human accounts are never included.
The installer promotes unused manual-summoning reserve accounts into the native
autonomous pool. The native random-bot manager maintains its configured population target using
unused factory-created characters. Replacements start at level 1, with their
own names and AzerothAI recipes/memories assigned when observed. This does not
reset or resurrect a dead character. Duplicate death events are harmless.
The reserve is finite: this deployment began with 450 unused level-1 characters.
This release does not create additional accounts or extend the factory reserve
when it is exhausted. The population will fall below target if no eligible
characters remain. Check `azerothai_lifecycle` state counts before extending it.
Death Knights are excluded from new random logins because they do not start at
level 1. Normal gameplay can still level a replacement after its initial entry.
The guard allocates new arrivals against actual living faction counts, targeting
25 Alliance and 25 Horde, including protected residents. It holds excess-faction
reserves and excess returning residents offline without retiring them. Returning
residents retain their progress and get priority over unused reserves. New activations record `entry_level=1`; subsequent leveling is allowed.
Native factory-created newcomers are enrolled only from verified RNDBOT accounts
and only when fresh and level 1.
## Installation
1. Build the updated AzerothAI application and start its runtime.
2. Run `python3 deploy/playable/install-lifecycle.py --lab /path/to/lab` to review.
3. Stop `ac-worldserver`.
4. Repeat the command with `--apply`. It backs up auth, character and playerbot
databases, current Lua scripts, compose overrides and the dashboard state.
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.
## Verification and recovery
Verify the lifecycle startup line, absence of lifecycle errors, zero retired
GUIDs online, new reserve activations at level 1, and the native target count.
The dashboard active count excludes unmanaged prototype entries in live mode.
For rollback, stop the world and restore its prior Lua directory and compose
override from the printed backup directory, then recreate the world container.
The previous characters were never deleted. Database restoration is a separate
operator action: it also rolls back gameplay since the backup timestamp.
Run `python3 tests/lifecycle-lua.test.py` with `lupa` installed for isolated
retirement, replay, protection and replacement-entry checks. Run
`node scripts/test-core.mjs` for application tests.
+1 -1
View File
@@ -104,7 +104,7 @@ async function deliverEvents(){
FROM acore_auth.azerothai_event_outbox
WHERE delivered_at IS NULL
${fresh.length?`AND id NOT IN (${fresh.map(row=>Number(row[0])).join(',')})`:''}
ORDER BY CASE WHEN kind IN ('player.whisper','player.chat') AND created_at >= NOW() - INTERVAL 90 SECOND THEN 0 ELSE 1 END, id
ORDER BY CASE WHEN kind IN ('player.permanent-death','bridge.ready') THEN 0 WHEN kind IN ('player.whisper','player.chat') AND created_at >= NOW() - INTERVAL 90 SECOND THEN 1 ELSE 2 END, id
LIMIT ${Math.max(1,EVENT_BATCH-fresh.length)}`);
for(const [id,kindHex,payloadHex] of [...fresh,...batch]){
const kind=Buffer.from(kindHex||'','hex').toString('utf8');
+13
View File
@@ -3,6 +3,7 @@ const assert=require('node:assert/strict');
const {execute}=require('../.test-build/service.js');
const {seed}=require('../.test-build/seed.js');
const {connectors}=require('../.test-build/connectors/index.js');
const {applyAuthoritativeAleEvent}=require('../.test-build/service.js');
class Store {
constructor(){this.state=seed();}
@@ -10,6 +11,18 @@ class Store {
async save(s,r){assert.equal(this.state.revision,r);s.revision=r+1;this.state=structuredClone(s);}
}
test('game lifecycle death closes identity once and preserves Zaku',async()=>{
const store=new Store();
const bot=store.state.bots.find(b=>b.id==='bot-6');
const payload={name:bot.name,isBot:true,characterGuid:99,accountId:2,deathId:'hardcore-99'};
await applyAuthoritativeAleEvent(store,'player.permanent-death',payload);
assert.equal(store.state.bots.find(b=>b.id===bot.id).status,'dead');
await applyAuthoritativeAleEvent(store,'player.permanent-death',payload);
assert.equal(store.state.events.filter(e=>e.kind==='death').length,1);
await applyAuthoritativeAleEvent(store,'player.permanent-death',{name:'Zaku',isBot:true,characterGuid:9,accountId:1});
assert.notEqual(store.state.bots.find(b=>b.name==='Zaku').status,'dead');
});
test('Zaku is a mortal persistent hero and hardcore starts enabled',()=>{
const state=seed();
const zaku=state.bots.find(b=>b.id==='hero-zaku');
+117
View File
@@ -0,0 +1,117 @@
"""Run with python3 after installing lupa; no live server or database involved."""
from pathlib import Path
from lupa import LuaRuntime
lua = LuaRuntime()
setup='''
rows={{10,"Dead",1,0,"dead",2},{11,"Zaku",1,1,"active",5},
{12,"Fresh",2,0,"reserve",2},{13,"Human",3,0,"active",1},
{14,"Wronglevel",2,0,"reserve",2}}
players={}; writes={}; removed={}; values={}; events={}; deaths=0
function player(guid,name,account,level,dead)
local p={guid=guid,name=name,account=account,level=level,dead=dead}
function p:GetGUIDLow() return self.guid end
function p:GetName() return self.name end
function p:GetAccountId() return self.account end
function p:GetLevel() return self.level end
function p:GetRace() return self.race or 2 end
function p:IsDead() return self.dead end
players[name]=p; return p
end
function AuthDBQuery(sql)
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
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
function q:NextRow() self.i=self.i+1;return self.i<=#rows end
return q
end
function CharDBQuery(sql) return {GetUInt32=function() return 0 end} end
function AuthDBExecute(sql) table.insert(writes,sql) end
mgr={}
function mgr:Remove(p) table.insert(removed,p.guid);players[p.name]=nil end
function mgr:SetValue(guid,key,value) values[guid..key]=value end
function mgr:GetValue(guid,key) return values[guid..key] or 0 end
function GetRandomPlayerbotMgr() return mgr end
function GetPlayerByName(name) return players[name] end
function GetPlayersInWorld() return players end
function RegisterPlayerEvent(id,fn) events[id]=fn end
function CreateLuaEvent(fn) tick=fn end
function PrintError(text) end
function PrintInfo(text) end
function AzerothAIEmitPermanentDeath(p) deaths=deaths+1 end
function AzerothAIEmitPlayerSnapshot(name) end
player(10,"Dead",1,80,false)
player(11,"Zaku",1,7,true)
player(12,"Fresh",2,1,false)
-- A mismatched account must never be managed, even if the GUID/name matches.
player(13,"Human",99,20,true)
player(14,"Wronglevel",2,55,false)
'''
lua.execute(setup)
lua.execute((Path(__file__).parents[1] / 'ale/azerothai_lifecycle.lua').read_text())
lua.execute('''
assert(#removed==1 and removed[1]==10)
assert(values['10logout']==1 and values['10add']==0)
assert(players.Zaku and players.Human)
assert(deaths==0)
tick();assert(not players.Wronglevel)
local fresh=players.Fresh
events[8](8,nil,fresh);events[8](8,nil,fresh);events[6](6,nil,fresh)
assert(deaths==1,'Repeated death events must be idempotent')
tick();assert(not players.Fresh and values['12logout']==1)
events[8](8,nil,players.Zaku);events[8](8,nil,players.Human)
assert(deaths==1 and players.Zaku and players.Human)
local before=#writes;tick();assert(#writes==before,'No per-second database churn')
-- A retired identity accidentally logged in again must be evicted.
player(10,'Dead',1,80,false);tick();assert(not players.Dead)
player(15,'Factorynew',2,1,false);tick();assert(players.Factorynew)
player(16,'Realhuman',99,1,true);tick();assert(players.Realhuman)
assert(deaths==1)
events[8](8,nil,players.Factorynew);tick();assert(not players.Factorynew)
assert(deaths==2)
local fast=player(17,'Fastlearner',2,1,false)
events[3](3,fast);fast.level=2;tick();assert(players.Fastlearner)
''')
balanced=LuaRuntime()
balanced.execute(setup)
balanced.execute('''
players={};rows={}
for i=1,25 do
rows[#rows+1]={100+i,'Horde'..i,2,0,'active',2}
player(100+i,'Horde'..i,2,5,false)
end
rows[#rows+1]={201,'Alliancefresh',2,0,'reserve',1}
rows[#rows+1]={202,'Hordefresh',2,0,'reserve',2}
player(201,'Alliancefresh',2,1,false)
player(202,'Hordefresh',2,1,false)
''')
balanced.execute((Path(__file__).parents[1] / 'ale/azerothai_lifecycle.lua').read_text())
balanced.execute('''
assert(players.Alliancefresh,'Underrepresented faction must fill its vacancy')
assert(not players.Hordefresh,'Do not exceed 25 members of a faction')
assert(values['202logout']==1)
assert(deaths==0,'Faction deferral is not a death')
''')
returning=LuaRuntime()
returning.execute(setup)
returning.execute("""
players={};rows={}
for i=1,26 do
rows[#rows+1]={100+i,'Horde'..i,2,0,'active',2}
player(100+i,'Horde'..i,2,5,false)
end
rows[#rows+1]={999,'Zaku',2,1,'active',2}
player(999,'Zaku',2,7,false)
""")
returning.execute((Path(__file__).parents[1] / 'ale/azerothai_lifecycle.lua').read_text())
returning.execute("""
local n=0;for _ in pairs(players) do n=n+1 end
assert(n==25 and players.Zaku,'Returning population must obey faction cap and protect Zaku')
assert(deaths==0,'Holding returning residents offline must not retire them')
assert(values['125logout']==1 and values['126logout']==1)
""")
print('PASS: retirement, protection, level-1 entry, replay safety, and 25/25 faction balancing')