diff --git a/ale/azerothai_bridge.lua b/ale/azerothai_bridge.lua index 0fd512a..9056345 100644 --- a/ale/azerothai_bridge.lua +++ b/ale/azerothai_bridge.lua @@ -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) diff --git a/ale/azerothai_lifecycle.lua b/ale/azerothai_lifecycle.lua new file mode 100644 index 0000000..6952d26 --- /dev/null +++ b/ale/azerothai_lifecycle.lua @@ -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.guid0 + 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") diff --git a/app/api/bridge/ale/event/route.ts b/app/api/bridge/ale/event/route.ts index 758a74b..6437f5d 100644 --- a/app/api/bridge/ale/event/route.ts +++ b/app/api/bridge/ale/event/route.ts @@ -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' ]); diff --git a/app/page.tsx b/app/page.tsx index 5a4039c..b73b0d7 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -41,7 +41,7 @@ export default function Home(){
{page==='GM Console'?'THE WORLD IS YOUR SANDBOX':'AZEROTHAI CONTROL PLANE'}

{page==='Dashboard'?'Your realm, alive.':page}

{({'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]}

{page==='Bot Recipes'?:<>}
{error&&
{error}
} {!state?
{[1,2,3,4].map(n=>)}
:<> - {page==='Dashboard'&&<>
{[[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)=>
{label}
{value}
{label==='Active bots'&&state.settings.connector!=='simulation'?'Acknowledged by the game bridge':note}
)}
YOUR NEXT ADVENTURE

{state.director.experience}

{state.director.zone} · {state.director.brief}

nav('Bots')}>Manage bots }>{botTable(state.bots.slice(0,5))}
{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
{zone}b.status==='online').length)*100}%`}}/>{n}
})}

Bot locations from the selected connector. Simulation locations are illustrative.

{state.settings.provider==='deterministic'?'Personality simulator':state.settings.model}

{state.settings.provider==='deterministic'?'No model required. Try a decision from any online bot.':'Local model selected. Each decision includes identity and memories.'}

nav('Event Stream')}>View stream }>{}

Set the scene. Announce a gathering. Move a character to the next chapter of their story.

<GM> AzerothAI

Welcome to your world.

.announce An adventure awaits...
} + {page==='Dashboard'&&<>
{[[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)=>
{label}
{value}
{label==='Active bots'&&state.settings.connector!=='simulation'?'Acknowledged by the game bridge':note}
)}
YOUR NEXT ADVENTURE

{state.director.experience}

{state.director.zone} · {state.director.brief}

nav('Bots')}>Manage bots }>{botTable(state.bots.slice(0,5))}
{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
{zone}b.status==='online').length)*100}%`}}/>{n}
})}

Bot locations from the selected connector. Simulation locations are illustrative.

{state.settings.provider==='deterministic'?'Personality simulator':state.settings.model}

{state.settings.provider==='deterministic'?'No model required. Try a decision from any online bot.':'Local model selected. Each decision includes identity and memories.'}

nav('Event Stream')}>View stream }>{}

Set the scene. Announce a gathering. Move a character to the next chapter of their story.

<GM> AzerothAI

Welcome to your world.

.announce An adventure awaits...
} {page==='World Monitor'&&} {page==='Bots'&&<>
setQuery(e.target.value)}/>{visible.length} characters
{visible.length?botTable(visible):
No matching bots. Create a character or adjust your filters.
}
} {page==='Bot Recipes'&&<>
Recipes are versioned. Saving a recipe preserves existing bots’ personalities until you explicitly apply the new version.
{state.recipes.map((r,i)=>)}
} diff --git a/core/service.ts b/core/service.ts index 91d0d51..9d0e392 100644 --- a/core/service.ts +++ b/core/service.ts @@ -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){ diff --git a/deploy/playable/install-lifecycle.py b/deploy/playable/install-lifecycle.py new file mode 100644 index 0000000..dedf758 --- /dev/null +++ b/deploy/playable/install-lifecycle.py @@ -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) diff --git a/docs/hardcore-replacement.md b/docs/hardcore-replacement.md new file mode 100644 index 0000000..e866b1a --- /dev/null +++ b/docs/hardcore-replacement.md @@ -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. diff --git a/scripts/ale-db-relay.mjs b/scripts/ale-db-relay.mjs index a41822b..9535eab 100644 --- a/scripts/ale-db-relay.mjs +++ b/scripts/ale-db-relay.mjs @@ -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'); diff --git a/tests/hardcore.test.cjs b/tests/hardcore.test.cjs index bb73298..7f7c203 100644 --- a/tests/hardcore.test.cjs +++ b/tests/hardcore.test.cjs @@ -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'); diff --git a/tests/lifecycle-lua.test.py b/tests/lifecycle-lua.test.py new file mode 100644 index 0000000..31d016c --- /dev/null +++ b/tests/lifecycle-lua.test.py @@ -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')