Enforce hardcore retirement with level-one faction-balanced replacements
This commit is contained in:
@@ -85,6 +85,11 @@ local function emit(kind, payload)
|
|||||||
AuthDBExecute(sql)
|
AuthDBExecute(sql)
|
||||||
end
|
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)
|
local function player_payload(player)
|
||||||
if not player then return {} end
|
if not player then return {} end
|
||||||
local isBot = is_playerbot(player)
|
local isBot = is_playerbot(player)
|
||||||
@@ -96,7 +101,7 @@ local function player_payload(player)
|
|||||||
mapId=player:GetMapId(),
|
mapId=player:GetMapId(),
|
||||||
zoneId=player:GetZoneId(),
|
zoneId=player:GetZoneId(),
|
||||||
areaId=player:GetAreaId(),
|
areaId=player:GetAreaId(),
|
||||||
zoneName=GetAreaName(player:GetZoneId()) or tostring(player:GetZoneId()),
|
zoneName=area_name(player:GetZoneId()),
|
||||||
race=player:GetRace(),
|
race=player:GetRace(),
|
||||||
class=player:GetClass(),
|
class=player:GetClass(),
|
||||||
raceName=player:GetRaceAsString() or tostring(player:GetRace()),
|
raceName=player:GetRaceAsString() or tostring(player:GetRace()),
|
||||||
@@ -136,6 +141,12 @@ function AzerothAIEmitPlayerSnapshot(name)
|
|||||||
return 1
|
return 1
|
||||||
end
|
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)
|
local function mark_consumed(id)
|
||||||
AuthDBExecute("UPDATE azerothai_command_inbox SET consumed_at=NOW() WHERE id=" .. db_text(to_hex(id)))
|
AuthDBExecute("UPDATE azerothai_command_inbox SET consumed_at=NOW() WHERE id=" .. db_text(to_hex(id)))
|
||||||
end
|
end
|
||||||
@@ -254,7 +265,7 @@ RegisterPlayerEvent(27, function(event, player, newZone, newArea)
|
|||||||
local payload=player_payload(player)
|
local payload=player_payload(player)
|
||||||
payload.zone=newZone
|
payload.zone=newZone
|
||||||
payload.area=newArea
|
payload.area=newArea
|
||||||
payload.zoneName=GetAreaName(newZone) or tostring(newZone)
|
payload.zoneName=area_name(newZone)
|
||||||
emit("player.zone",payload)
|
emit("player.zone",payload)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -25,6 +25,7 @@ const STATEFUL_EVENTS=new Set([
|
|||||||
'player.achievement',
|
'player.achievement',
|
||||||
'player.close-call',
|
'player.close-call',
|
||||||
'player.killed-by-creature',
|
'player.killed-by-creature',
|
||||||
|
'player.permanent-death',
|
||||||
'player.repop',
|
'player.repop',
|
||||||
'player.resurrect'
|
'player.resurrect'
|
||||||
]);
|
]);
|
||||||
|
|||||||
+1
-1
@@ -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>
|
<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>}
|
{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>:<>
|
{!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><GM> 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><GM> 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==='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==='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></>}
|
{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
@@ -179,7 +179,7 @@ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,pa
|
|||||||
b.memories=b.memories.slice(-100);
|
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);
|
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){
|
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);
|
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){
|
}else if(s.settings.hardcore.enabled){
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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.
|
||||||
@@ -104,7 +104,7 @@ async function deliverEvents(){
|
|||||||
FROM acore_auth.azerothai_event_outbox
|
FROM acore_auth.azerothai_event_outbox
|
||||||
WHERE delivered_at IS NULL
|
WHERE delivered_at IS NULL
|
||||||
${fresh.length?`AND id NOT IN (${fresh.map(row=>Number(row[0])).join(',')})`:''}
|
${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)}`);
|
LIMIT ${Math.max(1,EVENT_BATCH-fresh.length)}`);
|
||||||
for(const [id,kindHex,payloadHex] of [...fresh,...batch]){
|
for(const [id,kindHex,payloadHex] of [...fresh,...batch]){
|
||||||
const kind=Buffer.from(kindHex||'','hex').toString('utf8');
|
const kind=Buffer.from(kindHex||'','hex').toString('utf8');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const assert=require('node:assert/strict');
|
|||||||
const {execute}=require('../.test-build/service.js');
|
const {execute}=require('../.test-build/service.js');
|
||||||
const {seed}=require('../.test-build/seed.js');
|
const {seed}=require('../.test-build/seed.js');
|
||||||
const {connectors}=require('../.test-build/connectors/index.js');
|
const {connectors}=require('../.test-build/connectors/index.js');
|
||||||
|
const {applyAuthoritativeAleEvent}=require('../.test-build/service.js');
|
||||||
|
|
||||||
class Store {
|
class Store {
|
||||||
constructor(){this.state=seed();}
|
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);}
|
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',()=>{
|
test('Zaku is a mortal persistent hero and hardcore starts enabled',()=>{
|
||||||
const state=seed();
|
const state=seed();
|
||||||
const zaku=state.bots.find(b=>b.id==='hero-zaku');
|
const zaku=state.bots.find(b=>b.id==='hero-zaku');
|
||||||
|
|||||||
@@ -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')
|
||||||
Reference in New Issue
Block a user