23 changed files with 903 additions and 56 deletions
-1
View File
@@ -18,6 +18,5 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: node scripts/test-core.mjs - run: node scripts/test-core.mjs
-2
View File
@@ -18,7 +18,6 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- name: Core and simulation tests - name: Core and simulation tests
run: node scripts/test-core.mjs run: node scripts/test-core.mjs
@@ -62,7 +61,6 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- name: Install Ollama - name: Install Ollama
run: curl -fsSL https://ollama.com/install.sh | sh run: curl -fsSL https://ollama.com/install.sh | sh
-1
View File
@@ -31,7 +31,6 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
+42 -3
View File
@@ -47,6 +47,11 @@ local function json_value(value)
if value == nil then return "null" end if value == nil then return "null" end
if t == "boolean" then return value and "true" or "false" end if t == "boolean" then return value and "true" or "false" end
if t == "number" then return tostring(value) end if t == "number" then return tostring(value) end
if t == "table" then
local parts={}
for _,item in ipairs(value) do parts[#parts+1]=json_value(item) end
return "["..table.concat(parts,",").."]"
end
return '"' .. escape_json(value) .. '"' return '"' .. escape_json(value) .. '"'
end end
@@ -85,6 +90,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 +106,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 +146,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
@@ -172,16 +188,39 @@ CreateLuaEvent(function() poll() end, POLL_MS, 0)
-- Keep AzerothAI grounded in real online Playerbots even when no human is logged in. -- Keep AzerothAI grounded in real online Playerbots even when no human is logged in.
-- Expose a safe global refresh hook too, so the autonomy worker can explicitly -- Expose a safe global refresh hook too, so the autonomy worker can explicitly
-- request a fresh authoritative snapshot if the periodic timer ever stalls. -- request a fresh authoritative snapshot if the periodic timer ever stalls.
local socialLast={}
function AzerothAIEmitSnapshots() function AzerothAIEmitSnapshots()
local ok,players=pcall(function() return GetPlayersInWorld() end) local ok,players=pcall(function() return GetPlayersInWorld() end)
if not ok or type(players)~="table" then return 0 end if not ok or type(players)~="table" then return 0 end
local emitted=0 local emitted=0
local guids={}
for _,player in pairs(players) do for _,player in pairs(players) do
if is_playerbot(player) then if is_playerbot(player) then
emit("player.snapshot",player_payload(player)) guids[#guids+1]=player:GetGUIDLow()
emitted=emitted+1 emitted=emitted+1
end end
end end
-- Use snapshot transport so roster reconciliation shares fresh-event priority.
local roster={roster=true,observedAt=os.time(),characterGuids=guids}
local ordered={}
for _,p in pairs(players) do if is_playerbot(p) then ordered[#ordered+1]=p end end
table.sort(ordered,function(a,b) return (socialLast[a:GetGUIDLow()] or 0)<(socialLast[b:GetGUIDLow()] or 0) end)
for _,p in ipairs(ordered) do
if not p:IsDead() and not p:IsInCombat() and not p:IsMoving() and os.time()-(socialLast[p:GetGUIDLow()] or 0)>600 then
for _,q in pairs(p:GetPlayersInRange(25) or {}) do
if q:GetGUIDLow()~=p:GetGUIDLow() and is_playerbot(q) and not q:IsDead() and not q:IsInCombat() and not q:IsMoving() and p:GetDistance(q)<=15 and p:GetTeam()==q:GetTeam() and p:GetPhaseMask()==q:GetPhaseMask() and os.time()-(socialLast[q:GetGUIDLow()] or 0)>600 then
roster.socialA=p:GetName();roster.socialB=q:GetName();roster.zoneName=area_name(p:GetZoneId())
socialLast[p:GetGUIDLow()]=os.time();socialLast[q:GetGUIDLow()]=os.time()
break
end
end
end
if roster.socialA then break end
end
emit("player.snapshot",roster)
for _,player in pairs(players) do
if is_playerbot(player) then emit("player.snapshot",player_payload(player)) end
end
return emitted return emitted
end end
@@ -254,7 +293,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)
+194
View File
@@ -0,0 +1,194 @@
-- Persistent hardcore retirement. The native random-bot manager fills vacancies
-- from its factory-created, unused starting-level reserve; identities are never reset.
-- Install only after deploy/playable/install-lifecycle.py has seeded the registry.
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 startingLevel(player)
return player:GetClass()==6 and 55 or 1
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()==startingLevel(player) 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())~=startingLevel(player) then
row.state="blocked"
AuthDBExecute("UPDATE azerothai_lifecycle SET state='blocked' WHERE guid="..guid.." AND state='reserve'")
PrintError("[AzerothAI] Replacement did not start at its class starting level: "..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="..startingLevel(player)..",activated_at=COALESCE(activated_at,NOW()) WHERE guid="..guid.." AND state='reserve'")
if AzerothAIEmitPlayerSnapshot then AzerothAIEmitPlayerSnapshot(row.name) end
PrintInfo("[AzerothAI] New resident entered at class starting level: "..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")
+4 -1
View File
@@ -2,7 +2,7 @@ import { env } from 'cloudflare:workers';
import { z } from 'zod'; import { z } from 'zod';
import { requireAleBridge } from '@/core/ale-auth'; import { requireAleBridge } from '@/core/ale-auth';
import { recordAleEvent } from '@/core/ale-store'; import { recordAleEvent } from '@/core/ale-store';
import { queueConversationEvent } from '@/core/conversation-store'; import { queueSocialEncounter,queueConversationEvent } from '@/core/conversation-store';
import { D1Store } from '@/core/store'; import { D1Store } from '@/core/store';
import { applyAuthoritativeAleEvent,execute } from '@/core/service'; import { applyAuthoritativeAleEvent,execute } from '@/core/service';
import type { RuntimeConfig } from '@/core/types'; import type { RuntimeConfig } from '@/core/types';
@@ -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'
]); ]);
@@ -41,6 +42,8 @@ export async function POST(request:Request){
await queueConversationEvent(env.DB!,body.kind,body.payload); await queueConversationEvent(env.DB!,body.kind,body.payload);
} }
if(body.kind==='player.snapshot'&&body.payload.roster===true)await queueSocialEncounter(env.DB!,body.payload);
const store=new D1Store(); const store=new D1Store();
if(STATEFUL_EVENTS.has(body.kind)){ if(STATEFUL_EVENTS.has(body.kind)){
await applyAuthoritativeAleEvent(store,body.kind,body.payload); await applyAuthoritativeAleEvent(store,body.kind,body.payload);
+2 -2
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> <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>&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==='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></>}
@@ -61,7 +61,7 @@ function CreateBot({state,act,busy,close}:any){const [name,setName]=useState('')
function RecipeEditor({recipe,act,busy,close}:any){const [r,setR]=useState<Recipe>(recipe??{name:'',description:'',tags:['Curious'],sociability:50,aggression:20,curiosity:70,economy:30,activities:['explore','quest']});const [tags,setTags]=useState(r.tags.join(', '));return <form onSubmit={async e=>{e.preventDefault();try{await act({type:'saveRecipe',recipe:{...r,tags:tags.split(',').map(t=>t.trim()).filter(Boolean)}});toast.success('Recipe saved');close();}catch{}}}><div className="form-grid"><label className="field full">Recipe name<Input required minLength={2} maxLength={80} value={r.name} onChange={e=>setR({...r,name:e.target.value})}/></label><label className="field full">Personality description<textarea maxLength={500} value={r.description} onChange={e=>setR({...r,description:e.target.value})}/></label><label className="field full">Tags, separated by commas<Input value={tags} onChange={e=>setTags(e.target.value)}/></label></div><div className="section-grid" style={{gap:25,marginTop:20}}><div>{(['sociability','aggression','curiosity','economy'] as const).map(key=><div className="range-row" key={key}><div className="range-label"><span style={{textTransform:'capitalize'}}>{key}</span><span>{r[key]}</span></div><Slider aria-label={key} min={0} max={100} step={1} value={[r[key]]} onValueChange={v=>setR({...r,[key]:v[0]})}/></div>)}</div><div><h3 style={{margin:'15px 0'}}>Preferred activities</h3><div className="choices">{ACTIVITIES.map(a=><label className="choice-row" key={a}><Checkbox checked={r.activities.includes(a)} onCheckedChange={v=>setR({...r,activities:v?[...r.activities,a]:r.activities.filter(x=>x!==a)})}/>{a}</label>)}</div></div></div><div className="form-footer">{recipe&&<Button type="button" variant="outline" disabled={busy} onClick={async()=>{try{const {id,version,...copy}=r;void id;void version;await act({type:'saveRecipe',recipe:{...copy,name:`${copy.name} copy`.slice(0,80)}});close();}catch{}}}><Copy size={15}/>Duplicate</Button>}<Button disabled={busy||!r.activities.length}>Save recipe</Button></div></form>} function RecipeEditor({recipe,act,busy,close}:any){const [r,setR]=useState<Recipe>(recipe??{name:'',description:'',tags:['Curious'],sociability:50,aggression:20,curiosity:70,economy:30,activities:['explore','quest']});const [tags,setTags]=useState(r.tags.join(', '));return <form onSubmit={async e=>{e.preventDefault();try{await act({type:'saveRecipe',recipe:{...r,tags:tags.split(',').map(t=>t.trim()).filter(Boolean)}});toast.success('Recipe saved');close();}catch{}}}><div className="form-grid"><label className="field full">Recipe name<Input required minLength={2} maxLength={80} value={r.name} onChange={e=>setR({...r,name:e.target.value})}/></label><label className="field full">Personality description<textarea maxLength={500} value={r.description} onChange={e=>setR({...r,description:e.target.value})}/></label><label className="field full">Tags, separated by commas<Input value={tags} onChange={e=>setTags(e.target.value)}/></label></div><div className="section-grid" style={{gap:25,marginTop:20}}><div>{(['sociability','aggression','curiosity','economy'] as const).map(key=><div className="range-row" key={key}><div className="range-label"><span style={{textTransform:'capitalize'}}>{key}</span><span>{r[key]}</span></div><Slider aria-label={key} min={0} max={100} step={1} value={[r[key]]} onValueChange={v=>setR({...r,[key]:v[0]})}/></div>)}</div><div><h3 style={{margin:'15px 0'}}>Preferred activities</h3><div className="choices">{ACTIVITIES.map(a=><label className="choice-row" key={a}><Checkbox checked={r.activities.includes(a)} onCheckedChange={v=>setR({...r,activities:v?[...r.activities,a]:r.activities.filter(x=>x!==a)})}/>{a}</label>)}</div></div></div><div className="form-footer">{recipe&&<Button type="button" variant="outline" disabled={busy} onClick={async()=>{try{const {id,version,...copy}=r;void id;void version;await act({type:'saveRecipe',recipe:{...copy,name:`${copy.name} copy`.slice(0,80)}});close();}catch{}}}><Copy size={15}/>Duplicate</Button>}<Button disabled={busy||!r.activities.length}>Save recipe</Button></div></form>}
function BotProfile({bot:b,state,act,busy,perform,confirm}:any){const [memory,setMemory]=useState(''),[importance,setImportance]=useState(7),[target,setTarget]=useState(state.bots.find((x:Bot)=>x.id!==b.id)?.id??''),[affinity,setAffinity]=useState(40),[note,setNote]=useState(''),[recipeId,setRecipeId]=useState(b.recipeId);const actions=b.status==='online'?['pause','despawn']:b.status==='paused'?['resume','despawn']:b.status==='offline'?['spawn','retire']:[];return <><div className="detail-header"><Avatar bot={b}/><div><h2>{b.name}</h2><p className="subtitle">Level {b.level} {b.race} {b.role}</p></div><span className={`tag ${b.status}`} style={{marginLeft:'auto'}}>{b.status}</span></div><div className="actions">{actions.map(a=><Button key={a} size="sm" variant={a==='spawn'?'default':'outline'} disabled={busy} onClick={()=>a==='retire'?confirm({title:`Retire ${b.name}?`,description:'The identity and memories remain in your realm, but this bot cannot spawn again in this prototype.',action:{type:'lifecycle',botId:b.id,action:a}}):perform({type:'lifecycle',botId:b.id,action:a})}>{a==='pause'?<Pause size={14}/>:a==='spawn'||a==='resume'?<Play size={14}/>:null}{a.charAt(0).toUpperCase()+a.slice(1)}</Button>)}<Button size="sm" disabled={busy||b.status!=='online'} onClick={()=>perform({type:'think',botId:b.id})}><Brain size={15}/>{busy?'Working…':'Run decision'}</Button></div><Tabs defaultValue="identity" className="mt-6"><TabsList className="w-full"><TabsTrigger value="identity">Identity</TabsTrigger><TabsTrigger value="memory">Memory</TabsTrigger><TabsTrigger value="bonds">Relationships</TabsTrigger></TabsList><TabsContent value="identity"><div className="detail-block"><h3>Current goal</h3><p>{b.goal}</p><p style={{marginTop:12,display:'flex',gap:7,alignItems:'center'}}><MapPin size={15}/>{b.zone}</p></div><div className="detail-block"><h3>{b.personality.name} <small>v{b.recipeVersion}</small></h3><p>{b.personality.description}</p><div className="actions" style={{marginTop:12}}>{b.personality.tags.map((t:string)=><span className="tag" key={t}>{t}</span>)}</div><div className="field" style={{marginTop:20}}><Choice label="Apply recipe" value={recipeId} onChange={setRecipeId} items={state.recipes.map((r:Recipe)=>({value:r.id,label:`${r.name} · v${r.version}`}))}/><Button variant="outline" disabled={busy} onClick={()=>perform({type:'applyRecipe',botId:b.id,recipeId})}>Apply current recipe version</Button></div></div><div className="detail-block"><h3>Latest decision</h3><p>{b.lastDecision?`${b.lastDecision.action}: ${b.lastDecision.reason}`:'No decisions yet. Run a decision while this bot is online.'}</p><small>{b.decisions} decisions acknowledged</small></div></TabsContent><TabsContent value="memory"><form className="detail-block" onSubmit={async e=>{e.preventDefault();try{await act({type:'memory',botId:b.id,text:memory,importance});setMemory('');}catch{}}}><label className="field">Add a memory<textarea required value={memory} maxLength={1000} onChange={e=>setMemory(e.target.value)} placeholder="What should this character remember?"/></label><div className="range-row"><div className="range-label">Importance <span>{importance}/10</span></div><Slider aria-label="Memory importance" min={1} max={10} value={[importance]} onValueChange={v=>setImportance(v[0])}/></div><Button disabled={busy}>Save memory</Button></form>{[...b.memories].reverse().map((m:any)=><div className="memory" key={m.id}><p className="list-muted">{m.text}</p><small>{m.source} · Importance {m.importance}/10 · {new Date(m.at).toLocaleDateString()}</small></div>)}</TabsContent><TabsContent value="bonds"><p className="fineprint">Bonds are directional. One character’s trust does not imply the other’s.</p><form className="detail-block" onSubmit={async e=>{e.preventDefault();try{await act({type:'relationship',botId:b.id,targetId:target,affinity,note});setNote('');}catch{}}}><div className="field"><label>Other character</label><Choice label="Other character" value={target} onChange={setTarget} items={state.bots.filter((x:Bot)=>x.id!==b.id).map((x:Bot)=>({value:x.id,label:x.name}))}/></div><div className="range-row"><div className="range-label">Affinity <span>{affinity>0?'+':''}{affinity}</span></div><Slider aria-label="Affinity" min={-100} max={100} value={[affinity]} onValueChange={v=>setAffinity(v[0])}/></div><label className="field">Why this bond exists<textarea required maxLength={300} value={note} onChange={e=>setNote(e.target.value)}/></label><Button className="mt-4" disabled={busy||!target}>Save relationship</Button></form>{b.relationships.map((r:any)=><div className="memory" key={r.targetId}><h3>{state.bots.find((x:Bot)=>x.id===r.targetId)?.name} <span className="tag">{r.affinity>0?'+':''}{r.affinity}</span></h3><p className="list-muted">{r.note}</p></div>)}</TabsContent></Tabs></>} function BotProfile({bot:b,state,act,busy,perform,confirm}:any){const [memory,setMemory]=useState(''),[importance,setImportance]=useState(7),[target,setTarget]=useState(state.bots.find((x:Bot)=>x.id!==b.id)?.id??''),[affinity,setAffinity]=useState(40),[note,setNote]=useState(''),[recipeId,setRecipeId]=useState(b.recipeId);const actions=b.status==='online'?['pause','despawn']:b.status==='paused'?['resume','despawn']:b.status==='offline'?['spawn','retire']:[];return <><div className="detail-header"><Avatar bot={b}/><div><h2>{b.name}</h2><p className="subtitle">Level {b.level} {b.race} {b.role}</p></div><span className={`tag ${b.status}`} style={{marginLeft:'auto'}}>{b.status}</span></div><div className="actions">{actions.map(a=><Button key={a} size="sm" variant={a==='spawn'?'default':'outline'} disabled={busy} onClick={()=>a==='retire'?confirm({title:`Retire ${b.name}?`,description:'The identity and memories remain in your realm, but this bot cannot spawn again in this prototype.',action:{type:'lifecycle',botId:b.id,action:a}}):perform({type:'lifecycle',botId:b.id,action:a})}>{a==='pause'?<Pause size={14}/>:a==='spawn'||a==='resume'?<Play size={14}/>:null}{a.charAt(0).toUpperCase()+a.slice(1)}</Button>)}<Button size="sm" disabled={busy||b.status!=='online'} onClick={()=>perform({type:'think',botId:b.id})}><Brain size={15}/>{busy?'Working…':'Run decision'}</Button></div><Tabs defaultValue="identity" className="mt-6"><TabsList className="w-full"><TabsTrigger value="identity">Identity</TabsTrigger><TabsTrigger value="memory">Memory</TabsTrigger><TabsTrigger value="bonds">Relationships</TabsTrigger></TabsList><TabsContent value="identity"><div className="detail-block"><h3>Current goal</h3><p>{b.goal}</p><p style={{marginTop:12,display:'flex',gap:7,alignItems:'center'}}><MapPin size={15}/>{b.zone}</p></div><div className="detail-block"><h3>{b.personality.name} <small>v{b.recipeVersion}</small></h3><p>{b.personality.description}</p><div className="actions" style={{marginTop:12}}>{b.personality.tags.map((t:string)=><span className="tag" key={t}>{t}</span>)}</div><div className="field" style={{marginTop:20}}><Choice label="Apply recipe" value={recipeId} onChange={setRecipeId} items={state.recipes.map((r:Recipe)=>({value:r.id,label:`${r.name} · v${r.version}`}))}/><Button variant="outline" disabled={busy} onClick={()=>perform({type:'applyRecipe',botId:b.id,recipeId})}>Apply current recipe version</Button></div></div><div className="detail-block"><h3>Latest decision</h3><p>{b.lastDecision?`${b.lastDecision.action}: ${b.lastDecision.reason}`:'No decisions yet. Run a decision while this bot is online.'}</p><small>{b.decisions} decisions acknowledged</small></div></TabsContent><TabsContent value="memory"><form className="detail-block" onSubmit={async e=>{e.preventDefault();try{await act({type:'memory',botId:b.id,text:memory,importance});setMemory('');}catch{}}}><label className="field">Add a memory<textarea required value={memory} maxLength={1000} onChange={e=>setMemory(e.target.value)} placeholder="What should this character remember?"/></label><div className="range-row"><div className="range-label">Importance <span>{importance}/10</span></div><Slider aria-label="Memory importance" min={1} max={10} value={[importance]} onValueChange={v=>setImportance(v[0])}/></div><Button disabled={busy}>Save memory</Button></form>{[...b.memories].reverse().map((m:any)=><div className="memory" key={m.id}><p className="list-muted">{m.text}</p><small>{m.source} · Importance {m.importance}/10 · {new Date(m.at).toLocaleDateString()}</small></div>)}</TabsContent><TabsContent value="bonds"><p className="fineprint">Bonds are directional. One character’s trust does not imply the other’s.</p><form className="detail-block" onSubmit={async e=>{e.preventDefault();try{await act({type:'relationship',botId:b.id,targetId:target,affinity,note});setNote('');}catch{}}}><div className="field"><label>Other character</label><Choice label="Other character" value={target} onChange={setTarget} items={state.bots.filter((x:Bot)=>x.id!==b.id).map((x:Bot)=>({value:x.id,label:x.name}))}/></div><div className="range-row"><div className="range-label">Affinity <span>{affinity>0?'+':''}{affinity}</span></div><Slider aria-label="Affinity" min={-100} max={100} value={[affinity]} onValueChange={v=>setAffinity(v[0])}/></div><label className="field">Why this bond exists<textarea required maxLength={300} value={note} onChange={e=>setNote(e.target.value)}/></label><Button className="mt-4" disabled={busy||!target}>Save relationship</Button></form>{b.relationships.map((r:any)=><div className="memory" key={r.targetId}><h3>{state.bots.find((x:Bot)=>x.id===r.targetId)?.name} <span className="tag">{r.affinity>0?'+':''}{r.affinity}</span></h3><p className="list-muted">{r.note}</p></div>)}</TabsContent></Tabs></>}
function WorldMonitor({state,monitor,capabilities,openBot}:any){ function WorldMonitor({state,monitor,capabilities,openBot}:any){
const living=state.bots.filter((b:Bot)=>b.status!=='dead'&&b.status!=='retired'); const living:Bot[]=state.bots.filter((b:Bot)=>b.status!=='dead'&&b.status!=='retired');
const active=living.filter((b:Bot)=>b.status==='online'); const active=living.filter((b:Bot)=>b.status==='online');
const managed=living.filter((b:Bot)=>b.autonomy?.managed===true); const managed=living.filter((b:Bot)=>b.autonomy?.managed===true);
const foreground=living.filter((b:Bot)=>b.simulationBand==='foreground'); const foreground=living.filter((b:Bot)=>b.simulationBand==='foreground');
+19 -7
View File
@@ -103,8 +103,8 @@ export async function runLiveAutonomyTick(
options:{forceBotId?:string;now?:number}={} options:{forceBotId?:string;now?:number}={}
){ ){
const now=options.now??Date.now(); const now=options.now??Date.now();
const s=await store.load(); let s=await store.load();
const revision=s.revision; let revision=s.revision;
const events=await recentAleEvents(db,400); const events=await recentAleEvents(db,400);
const candidates=s.bots const candidates=s.bots
@@ -139,29 +139,41 @@ export async function runLiveAutonomyTick(
return {ok:true,ran:false,reason:options.forceBotId?'No recent authoritative observation for that managed Playerbot':'No managed Playerbot is due with a recent authoritative observation'}; return {ok:true,ran:false,reason:options.forceBotId?'No recent authoritative observation for that managed Playerbot':'No managed Playerbot is due with a recent authoritative observation'};
} }
const bot=pick.bot; let bot=pick.bot;
const observation=observationFrom(bot,pick.obs,events); const observation=observationFrom(bot,pick.obs,events);
const brain=providerForBot(bot,s.settings,config); const brain=providerForBot(bot,s.settings,config);
let decision:Decision; let decision:Decision;
let usedFallback=false;
try{ try{
decision=await brain.provider.decide(bot,observation); decision=await brain.provider.decide(bot,observation);
}catch(error){ }catch(error){
if(brain.route.provider==='deterministic')throw error; if(brain.route.provider==='deterministic')throw error;
decision=await new DeterministicProvider().decide(bot,observation); decision=await new DeterministicProvider().decide(bot,observation);
realmEvent(s,'system',`${bot.name} LLM route unavailable; used deterministic fallback for this heartbeat.`,bot.id); usedFallback=true;
} }
// Model inference can take seconds while authoritative events update state.
// Commit the decision against current state, not the pre-inference revision.
const botId=bot.id;
s=await store.load();
revision=s.revision;
const currentBot=s.bots.find(b=>b.id===botId);
if(!currentBot||currentBot.status==='dead'||currentBot.status==='retired'||!currentBot.autonomy?.managed){
return {ok:true,ran:false,reason:'Resident is no longer eligible for an autonomous intention'};
}
bot=currentBot;
if(usedFallback)realmEvent(s,'system',`${bot.name} LLM route unavailable; used deterministic fallback for this heartbeat.`,bot.id);
const source=compileLivePlayerbotIntent(bot.name,decision); const source=compileLivePlayerbotIntent(bot.name,decision);
const queued=await queueAleCommand(db,source); const queued=await queueAleCommand(db,source);
bot.lastDecision=decision; bot.lastDecision=decision;
bot.decisions++; bot.decisions++;
bot.goal=decision.goal; bot.goal=decision.goal;
bot.level=observation.level; const newerObservation=Date.parse(bot.autonomy?.observedAt??'')>Date.parse(pick.obs.at);
bot.zone=observation.zone; if(!newerObservation){bot.level=observation.level;bot.zone=observation.zone;}
bot.autonomy={ bot.autonomy={
...(bot.autonomy??{}), ...(bot.autonomy??{}),
managed:true, managed:true,
observedAt:pick.obs.at, observedAt:newerObservation?bot.autonomy?.observedAt:pick.obs.at,
lastIntentAt:new Date(now).toISOString(), lastIntentAt:new Date(now).toISOString(),
nextIntentAt:new Date(now+nextIntervalMs(bot)).toISOString(), nextIntentAt:new Date(now+nextIntervalMs(bot)).toISOString(),
lastAleCommandId:queued.id lastAleCommandId:queued.id
+46 -2
View File
@@ -1,6 +1,7 @@
export type ConversationChannel='say'|'whisper'; export type ConversationChannel='say'|'whisper';
export type ConversationRequest={ export type ConversationRequest={
socialTurn?:0|1;
id:string; id:string;
createdAt:string; createdAt:string;
speakerName:string; speakerName:string;
@@ -10,7 +11,7 @@ export type ConversationRequest={
receiver:string; receiver:string;
selectedPlayer:string; selectedPlayer:string;
nearbyBots:string[]; nearbyBots:string[];
status:'pending'|'processing'|'completed'|'failed'; status:'pending'|'processing'|'awaiting_delivery'|'completed'|'failed';
attempts:number; attempts:number;
}; };
@@ -79,7 +80,7 @@ export async function leaseConversation(db:D1Database):Promise<ConversationReque
.bind(staleBefore).run(); .bind(staleBefore).run();
const row=await db.prepare(`SELECT id,created_at,speaker_name,message,channel,zone_name,receiver,selected_player,nearby_bots,status,attempts const row=await db.prepare(`SELECT id,created_at,speaker_name,message,channel,zone_name,receiver,selected_player,nearby_bots,status,attempts
FROM conversation_requests WHERE status='pending' ORDER BY created_at LIMIT 1`).first<any>(); FROM conversation_requests WHERE status='pending' ORDER BY CASE WHEN channel LIKE 'social-%' THEN 1 ELSE 0 END,created_at LIMIT 1`).first<any>();
if(!row)return null; if(!row)return null;
const startedAt=new Date().toISOString(); const startedAt=new Date().toISOString();
@@ -89,6 +90,7 @@ export async function leaseConversation(db:D1Database):Promise<ConversationReque
if(updated.meta.changes!==1)return null; if(updated.meta.changes!==1)return null;
return { return {
socialTurn:row.channel==='social-start'?0:row.channel==='social-reply'?1:undefined,
id:row.id, id:row.id,
createdAt:row.created_at, createdAt:row.created_at,
speakerName:row.speaker_name, speakerName:row.speaker_name,
@@ -124,3 +126,45 @@ export async function cleanupConversations(db:D1Database){
WHERE status IN ('completed','failed') AND completed_at IS NOT NULL AND completed_at<?`) WHERE status IN ('completed','failed') AND completed_at IS NOT NULL AND completed_at<?`)
.bind(before).run(); .bind(before).run();
} }
export async function awaitDelivery(db:D1Database,request:ConversationRequest,botId:string,reply:string,commandId:string){
await db.prepare("UPDATE conversation_requests SET status='awaiting_delivery',result=? WHERE id=?")
.bind(JSON.stringify({request,botId,reply,commandId}),request.id).run();
}
export async function pendingDeliveries(db:D1Database){
await ensure(db);
const rows=await db.prepare("SELECT result FROM conversation_requests WHERE status='awaiting_delivery' ORDER BY created_at LIMIT 50").all<{result:string}>();
const deliveries=[];
for(const row of rows.results){
const delivery=JSON.parse(row.result) as {request:ConversationRequest;botId:string;reply:string;commandId:string};
const command=await db.prepare('SELECT status,error FROM ale_commands WHERE id=?').bind(delivery.commandId).first<{status:string;error:string|null}>();
deliveries.push({...delivery,status:command?.status??'pending',error:command?.error});
}
return deliveries;
}
// One autonomous exchange per two-minute bucket; human chat keeps priority.
export async function queueSocialEncounter(db:D1Database,payload:Record<string,unknown>){
const a=text(payload,'socialA',40),b=text(payload,'socialB',40);
const at=Number(payload.observedAt);
if(!a||!b||a===b||!Number.isFinite(at)||Math.abs(Date.now()/1000-at)>60)return;
await ensure(db);
const now=new Date().toISOString();
const cutoff=new Date(Date.now()-10*60_000).toISOString();
const id='social-'+Math.floor(Date.now()/120_000);
await db.prepare(`INSERT OR IGNORE INTO conversation_requests
(id,created_at,speaker_name,message,channel,zone_name,receiver,selected_player,nearby_bots,status,attempts)
SELECT ?,?,?,?,'social-start',?,?,?,?,'pending',0
WHERE NOT EXISTS (SELECT 1 FROM conversation_requests WHERE status IN ('pending','processing','awaiting_delivery'))
AND NOT EXISTS (SELECT 1 FROM conversation_requests WHERE channel LIKE 'social-%' AND created_at>? AND (speaker_name IN (?,?) OR selected_player IN (?,?)))`)
.bind(id,now,b,'Initiate a brief friendly in-world conversation with your nearby companion.',text(payload,'zoneName',120),a,a,b,cutoff,a,b,a,b).run();
}
export async function queueSocialReply(db:D1Database,request:ConversationRequest,botName:string,reply:string){
if(request.socialTurn!==0)return;
await db.prepare(`INSERT OR IGNORE INTO conversation_requests
(id,created_at,speaker_name,message,channel,zone_name,receiver,selected_player,nearby_bots,status,attempts)
VALUES (?,?,?,?,'social-reply',?,?,?,?,'pending',0)`)
.bind(request.id+':reply',new Date().toISOString(),botName,reply,request.zoneName,request.speakerName,request.speakerName,botName).run();
}
+40 -21
View File
@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { queueAleCommand } from './ale-store'; import { queueAleCommand } from './ale-store';
import { luaString } from './ale'; import { luaString } from './ale';
import { cleanupConversations,completeConversation,failConversation,leaseConversation,type ConversationRequest } from './conversation-store'; import { queueSocialReply,cleanupConversations,completeConversation,failConversation,leaseConversation,awaitDelivery,pendingDeliveries,type ConversationRequest } from './conversation-store';
import type { Bot,RealmEvent,RuntimeConfig,State,StateStore } from './types'; import type { Bot,RealmEvent,RuntimeConfig,State,StateStore } from './types';
const replySchema=z.object({reply:z.string().min(1).max(500)}).strict(); const replySchema=z.object({reply:z.string().min(1).max(500)}).strict();
@@ -20,7 +20,7 @@ function byName(state:State,name:string){
if(!lower)return undefined; if(!lower)return undefined;
return state.bots.find(bot=> return state.bots.find(bot=>
bot.autonomy?.managed===true&& bot.autonomy?.managed===true&&
bot.status!=='dead'&&bot.status!=='retired'&& bot.status==='online'&&
bot.name.toLowerCase()===lower bot.name.toLowerCase()===lower
); );
} }
@@ -54,14 +54,8 @@ function chooseTarget(state:State,request:ConversationRequest){
return undefined; return undefined;
} }
function fallbackReply(bot:Bot,request:ConversationRequest){
if(/\b(hello|hi|hey|yo)\b/i.test(request.message))return `Hello, ${request.speakerName}.`;
if(request.message.includes('?'))return `I'm not sure, ${request.speakerName}. We can figure it out together.`;
return `I hear you, ${request.speakerName}.`;
}
async function modelReply(bot:Bot,request:ConversationRequest,state:State,config:RuntimeConfig){ async function modelReply(bot:Bot,request:ConversationRequest,state:State,config:RuntimeConfig){
if(!config.LLM_URL||state.settings.provider==='deterministic')return fallbackReply(bot,request); if(!config.LLM_URL||state.settings.provider==='deterministic')throw new Error('Conversation model is not configured; no canned reply was sent');
const brains=state.settings.brains; const brains=state.settings.brains;
const hero=bot.tier==='hero'; const hero=bot.tier==='hero';
@@ -79,7 +73,7 @@ async function modelReply(bot:Bot,request:ConversationRequest,state:State,config
content:JSON.stringify({ content:JSON.stringify({
speaker:request.speakerName, speaker:request.speakerName,
channel:request.channel, channel:request.channel,
message:request.message, message:request.socialTurn===0?'Start a conversation with '+request.speakerName+' about your current surroundings or goals. They have not spoken yet.':request.message,
currentZone:request.zoneName||bot.zone, currentZone:request.zoneName||bot.zone,
identity:{name:bot.name,race:bot.race,role:bot.role,tier:bot.tier}, identity:{name:bot.name,race:bot.race,role:bot.role,tier:bot.tier},
personality:bot.personality, personality:bot.personality,
@@ -111,11 +105,15 @@ async function modelReply(bot:Bot,request:ConversationRequest,state:State,config
return cleanReply(replySchema.parse(JSON.parse(raw)).reply); return cleanReply(replySchema.parse(JSON.parse(raw)).reply);
} }
function compileReply(botName:string,speakerName:string,reply:string,channel:'say'|'whisper'){ function compileReply(botName:string,speakerName:string,reply:string,channel:'say'|'whisper',social=false){
const source=[ const source=[
`local bot=GetPlayerByName(${luaString(botName)})`, `local bot=GetPlayerByName(${luaString(botName)})`,
'if not bot then error("Conversation bot is not online") end' 'if not bot then error("Conversation bot is not online") end'
]; ];
if(social){
source.push(`local peer=GetPlayerByName(${luaString(speakerName)})`);
source.push('if not peer or bot:IsDead() or peer:IsDead() or bot:IsInCombat() or peer:IsInCombat() or bot:GetMapId()~=peer:GetMapId() or bot:GetPhaseMask()~=peer:GetPhaseMask() or bot:GetTeam()~=peer:GetTeam() or bot:GetDistance(peer)>25 then error("Social pair is no longer available nearby") end');
}
if(channel==='whisper'){ if(channel==='whisper'){
source.push(`local receiver=GetPlayerByName(${luaString(speakerName)})`); source.push(`local receiver=GetPlayerByName(${luaString(speakerName)})`);
source.push('if not receiver then error("Conversation player is not online") end'); source.push('if not receiver then error("Conversation player is not online") end');
@@ -133,14 +131,28 @@ async function remember(store:StateStore,botId:string,request:ConversationReques
const revision=state.revision; const revision=state.revision;
const bot=state.bots.find(x=>x.id===botId); const bot=state.bots.find(x=>x.id===botId);
if(!bot)return; if(!bot)return;
if(bot.memories.some(memory=>memory.id===request.id))return;
bot.memories.push({ bot.memories.push({
id:crypto.randomUUID(), id:request.id,
text:`${request.speakerName} said: "${request.message.slice(0,180)}" I replied: "${reply.slice(0,180)}"`, text:`${request.speakerName} said: "${request.message.slice(0,180)}" I replied: "${reply.slice(0,180)}"`,
importance:request.channel==='whisper'?7:6, importance:request.channel==='whisper'?7:6,
source:'observation', source:'observation',
at:new Date().toISOString() at:new Date().toISOString()
}); });
bot.memories=bot.memories.slice(-100); bot.memories=bot.memories.slice(-100);
if(request.socialTurn===1){
const peer=byName(state,request.speakerName);
if(peer){
peer.memories.push({id:request.id,text:`I said to ${bot.name}: "${request.message.slice(0,180)}" They replied: "${reply.slice(0,180)}"`,importance:6,source:'observation',at:new Date().toISOString()});
peer.memories=peer.memories.slice(-100);
for(const [from,to] of [[bot,peer],[peer,bot]]){
const existing=from.relationships.find(r=>r.targetId===to.id);
if(existing)existing.note='We have spoken together in person.';
else from.relationships.push({targetId:to.id,affinity:0,note:'We have spoken together in person.'});
from.relationships=from.relationships.slice(-100);
}
}
}
bot.simulationBand='foreground'; bot.simulationBand='foreground';
realmEvent(state,'memory',`${bot.name} had a live conversation with ${request.speakerName}.`,bot.id); realmEvent(state,'memory',`${bot.name} had a live conversation with ${request.speakerName}.`,bot.id);
try{ try{
@@ -153,10 +165,23 @@ async function remember(store:StateStore,botId:string,request:ConversationReques
} }
export async function runConversationTick(store:StateStore,db:D1Database,config:RuntimeConfig){ export async function runConversationTick(store:StateStore,db:D1Database,config:RuntimeConfig){
for(const delivery of await pendingDeliveries(db)){
if(delivery.status==='completed'){
if(delivery.request.socialTurn!==0)await remember(store,delivery.botId,delivery.request,delivery.reply);
const state=await store.load();
const speaker=state.bots.find(b=>b.id===delivery.botId);
if(speaker)await queueSocialReply(db,delivery.request,speaker.name,delivery.reply);
await completeConversation(db,delivery.request.id,JSON.stringify({commandId:delivery.commandId,delivered:true}));
}else if(delivery.status==='failed'){
await failConversation(db,delivery.request.id,delivery.error||'Game rejected conversation delivery');
}
}
await cleanupConversations(db);
const request=await leaseConversation(db); const request=await leaseConversation(db);
if(!request)return {ok:true,ran:false,reason:'No player conversation waiting'}; if(!request)return {ok:true,ran:false,reason:'No player conversation waiting'};
try{ try{
if(request.socialTurn!==undefined&&Date.now()-Date.parse(request.createdAt)>120_000)throw new Error('Social encounter expired');
const state=await store.load(); const state=await store.load();
const bot=chooseTarget(state,request); const bot=chooseTarget(state,request);
if(!bot){ if(!bot){
@@ -164,18 +189,12 @@ export async function runConversationTick(store:StateStore,db:D1Database,config:
return {ok:true,ran:false,reason:'No addressed managed Playerbot matched',speaker:request.speakerName}; return {ok:true,ran:false,reason:'No addressed managed Playerbot matched',speaker:request.speakerName};
} }
let reply:string; let reply=await modelReply(bot,request,state,config);
try{
reply=await modelReply(bot,request,state,config);
}catch{
reply=fallbackReply(bot,request);
}
reply=cleanReply(reply); reply=cleanReply(reply);
if(!reply)throw new Error('Conversation reply was empty'); if(!reply)throw new Error('Conversation reply was empty');
const command=await queueAleCommand(db,compileReply(bot.name,request.speakerName,reply,request.channel)); const command=await queueAleCommand(db,compileReply(bot.name,request.speakerName,reply,request.channel,request.socialTurn!==undefined));
await remember(store,bot.id,request,reply); await awaitDelivery(db,request,bot.id,reply,command.id);
await completeConversation(db,request.id,JSON.stringify({bot:bot.name,reply,commandId:command.id}));
await cleanupConversations(db); await cleanupConversations(db);
return {ok:true,ran:true,speaker:request.speakerName,name:bot.name,channel:request.channel,reply,commandId:command.id}; return {ok:true,ran:true,speaker:request.speakerName,name:bot.name,channel:request.channel,reply,commandId:command.id};
+24 -6
View File
@@ -72,6 +72,24 @@ function observedHardcoreDeath(s:State,b:Bot,observation:Observation){
export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,payload:Record<string,unknown>){ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,payload:Record<string,unknown>){
let s=await store.load(); let s=await store.load();
const revision=s.revision; const revision=s.revision;
// Complete world snapshots own presence once available; historical events
// must never bring disconnected residents back online.
if(kind==='player.snapshot' && payload.roster===true){
const at=payload.observedAt;
const guids=payload.characterGuids;
if(typeof at!=='number'||!Number.isFinite(at)||!Array.isArray(guids)||!guids.every(g=>Number.isInteger(g)&&g>0))throw new Error('Invalid authoritative roster');
if(at<=(s.aleRosterAt??0))return {matched:false,stale:true};
const online=new Set(guids);
for(const bot of s.bots){
if(bot.status==='dead'||bot.status==='retired')continue;
const present=bot.autonomy?.managed && online.has(bot.autonomy.characterGuid);
bot.status=present?'online':'offline';
bot.simulationBand=present?'background':'dormant';
}
s.aleRosterAt=at;
await store.save(s,revision);
return {matched:true,roster:true};
}
if(kind==='bridge.ready'){ if(kind==='bridge.ready'){
let changed=false; let changed=false;
for(const bot of s.bots){ for(const bot of s.bots){
@@ -103,7 +121,7 @@ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,pa
const isZaku=name.toLowerCase()==='zaku'; const isZaku=name.toLowerCase()==='zaku';
b={ b={
id:isZaku?'hero-zaku':`live-${crypto.randomUUID()}`, id:isZaku?'hero-zaku':`live-${crypto.randomUUID()}`,
name,role,race,level,zone,status:'online', name,role,race,level,zone,status:s.aleRosterAt?'offline':'online',
tier:isZaku?'hero':'resident',mortal:true,plotArmorHpFloor:isZaku?1:0, tier:isZaku?'hero':'resident',mortal:true,plotArmorHpFloor:isZaku?1:0,
simulationBand:'background', simulationBand:'background',
routine:{wakeHour:isZaku?10:7,sleepHour:isZaku?3:23,preferredZones:[zone]}, routine:{wakeHour:isZaku?10:7,sleepHour:isZaku?3:23,preferredZones:[zone]},
@@ -137,19 +155,19 @@ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,pa
if(zone)b.zone=zone; if(zone)b.zone=zone;
if(kind==='player.login'){ if(kind==='player.login'){
b.status='online'; if(!s.aleRosterAt)b.status='online';
b.simulationBand='foreground'; b.simulationBand='foreground';
event(s,'lifecycle',`${b.name} logged into the authoritative AzerothCore world.`,b.id); event(s,'lifecycle',`${b.name} logged into the authoritative AzerothCore world.`,b.id);
}else if(kind==='player.logout'){ }else if(kind==='player.logout'){
b.status='offline'; if(!s.aleRosterAt)b.status='offline';
b.simulationBand='dormant'; b.simulationBand='dormant';
event(s,'lifecycle',`${b.name} logged out of the authoritative AzerothCore world.`,b.id); event(s,'lifecycle',`${b.name} logged out of the authoritative AzerothCore world.`,b.id);
}else if(kind==='player.zone'||kind==='player.map'){ }else if(kind==='player.zone'||kind==='player.map'){
if(b.status==='offline')b.status='online'; if(!s.aleRosterAt&&b.status==='offline')b.status='online';
b.simulationBand='foreground'; b.simulationBand='foreground';
event(s,'lifecycle',`${b.name} is now in ${b.zone} (authoritative ALE observation).`,b.id); event(s,'lifecycle',`${b.name} is now in ${b.zone} (authoritative ALE observation).`,b.id);
}else if(kind==='player.snapshot'){ }else if(kind==='player.snapshot'){
if(b.status==='offline')b.status='online'; if(!s.aleRosterAt&&b.status==='offline')b.status='online';
if(b.simulationBand==='dormant')b.simulationBand='background'; if(b.simulationBand==='dormant')b.simulationBand='background';
}else if(kind==='player.level'){ }else if(kind==='player.level'){
const text=`I reached level ${b.level} while living in ${b.zone}.`; const text=`I reached level ${b.level} while living in ${b.zone}.`;
@@ -179,7 +197,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){
+1 -1
View File
@@ -18,7 +18,7 @@ export type PopulationSettings = {defaultZoneTarget:number; zoneMax:number; serv
export type BrainRouterSettings = {enabled:boolean; residentModel:string; heroModel:string; backgroundUseLLM:boolean; residentMemoryLimit:number; heroMemoryLimit:number; residentMaxTokens:number; heroMaxTokens:number}; export type BrainRouterSettings = {enabled:boolean; residentModel:string; heroModel:string; backgroundUseLLM:boolean; residentMemoryLimit:number; heroMemoryLimit:number; residentMaxTokens:number; heroMaxTokens:number};
export type WorldTrigger = {id:string; kind:'zone-change'|'interaction'|'time'|'manual'; zone:string; at:string; playerName?:string; detail?:string}; export type WorldTrigger = {id:string; kind:'zone-change'|'interaction'|'time'|'manual'; zone:string; at:string; playerName?:string; detail?:string};
export type PopulationPlan = {id:string; trigger:WorldTrigger; target:number; activeBefore:number; requested:number; capacity:number; selectedBotIds:string[]; rejectedReasons:string[]; createdAt:string}; export type PopulationPlan = {id:string; trigger:WorldTrigger; target:number; activeBefore:number; requested:number; capacity:number; selectedBotIds:string[]; rejectedReasons:string[]; createdAt:string};
export type State = {revision:number; bots:Bot[]; homes:Home[]; recipes:Recipe[]; events:RealmEvent[]; sequence:number; settings:{connector:'simulation'|'azerothcore'; provider:'deterministic'|'ollama'|'openai-compatible'; model:string; brains:BrainRouterSettings; hardcore:HardcoreSettings; population:PopulationSettings}; director:{experience:string; zone:string; playerName:string; brief:string}; population:{lastPlan?:PopulationPlan}; operations:Record<string,{started:number; botId:string}>}; export type State = {revision:number; aleRosterAt?:number; bots:Bot[]; homes:Home[]; recipes:Recipe[]; events:RealmEvent[]; sequence:number; settings:{connector:'simulation'|'azerothcore'; provider:'deterministic'|'ollama'|'openai-compatible'; model:string; brains:BrainRouterSettings; hardcore:HardcoreSettings; population:PopulationSettings}; director:{experience:string; zone:string; playerName:string; brief:string}; population:{lastPlan?:PopulationPlan}; operations:Record<string,{started:number; botId:string}>};
export type DeathSignal = {id:string; at:string; cause:string; killer?:string; zone?:string; verified:boolean}; export type DeathSignal = {id:string; at:string; cause:string; killer?:string; zone?:string; verified:boolean};
export type Observation = {zone:string; level:number; availableActions:Activity[]; summary:string; alive:boolean; death?:DeathSignal}; export type Observation = {zone:string; level:number; availableActions:Activity[]; summary:string; alive:boolean; death?:DeathSignal};
export interface GameConnector {id:string; health():Promise<{ok:boolean; detail:string}>; observe(bot:Bot):Promise<Observation>; lifecycle(bot:Bot,action:'spawn'|'pause'|'resume'|'despawn'|'retire',requestId:string):Promise<void>; decide(bot:Bot,decision:Decision,requestId:string):Promise<{summary:string;zone?:string}>;} export interface GameConnector {id:string; health():Promise<{ok:boolean; detail:string}>; observe(bot:Bot):Promise<Observation>; lifecycle(bot:Bot,action:'spawn'|'pause'|'resume'|'despawn'|'retire',requestId:string):Promise<void>; decide(bot:Bot,decision:Decision,requestId:string):Promise<{summary:string;zone?:string}>;}
+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) == (55 if int(cls) == 6 else 1) and int(played) == 0:
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 starting-level 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': '0'}
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)
+24
View File
@@ -0,0 +1,24 @@
# Autonomous conversations
The live bridge proposes nearby, same-faction stationary bot pairs within 15 yards. Delivery allows up to 25 yards. Both
must be alive, out of combat and in the same phase. Opportunities ride the fresh
roster snapshot transport. The database admits at most one exchange per two-minute
clock bucket and enforces ten minutes between social requests involving a bot.
Human requests take queue priority. Ordinary bot chatter does not trigger replies.
An exchange consists of a model-generated opening and one model-generated reply.
Each delivery rechecks distance, faction, phase, life and combat state in the game.
Expired requests and pairs that have moved apart are cancelled. No characters are
teleported or held in place for conversations.
Only a confirmed reply creates reciprocal observation memories and neutral
acquaintance records. This does not invent friendship or change affinity based
solely on speaking. Replay IDs prevent duplicate memories. Each resident retains
at most 100 memories and 100 relationship records on this path. Completed and
failed conversation requests are pruned after 24 hours; character memorials and
other game/event history have separate retention policies.
Memories and relationships are stored in `realm_state.payload` in the application's
D1 SQLite database under `.wrangler/state/v3/d1` in the Ubuntu deployment. This
uses the VM's disk, backed by the host PC's storage. The per-resident memory limit
is not a total disk quota: additional identities and other logs can still grow.
+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 (Death Knights at level 55), 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 eligible and count toward their race’s faction, within the same
25-per-faction cap. Their approved starting level is 55. 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` or `entry_level=55` for Death Knights; subsequent leveling is allowed.
Native factory-created newcomers are enrolled only from verified RNDBOT accounts
and only when fresh and at their class starting level.
## 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.
+2 -2
View File
@@ -9,11 +9,11 @@
"install:ci": "bash scripts/install-pnpm.sh", "install:ci": "bash scripts/install-pnpm.sh",
"dev": "node scripts/run-framework.mjs dev", "dev": "node scripts/run-framework.mjs dev",
"build": "node scripts/run-framework.mjs build", "build": "node scripts/run-framework.mjs build",
"start": "node --import ./scripts/sites-env.mjs ./node_modules/wrangler/bin/wrangler.js dev --config dist/server/wrangler.json --local --persist-to .wrangler/state --ip 127.0.0.1 --inspector-port 0 --env-file .dev.vars", "start": "node scripts/start-local-runtime.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next", "lint": "eslint . --ignore-pattern dist --ignore-pattern .next",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"bench:brains": "node scripts/benchmark-brains.mjs", "bench:brains": "node scripts/benchmark-brains.mjs",
"start:lan": "node --import ./scripts/sites-env.mjs ./node_modules/wrangler/bin/wrangler.js dev --config dist/server/wrangler.json --local --persist-to .wrangler/state --ip 0.0.0.0 --port 8787 --inspector-port 0 --env-file .dev.vars" "start:lan": "node scripts/start-local-runtime.mjs --lan"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.7.0", "@base-ui/react": "^1.7.0",
+21 -5
View File
@@ -93,12 +93,20 @@ function rows(sql){
} }
async function deliverEvents(){ async function deliverEvents(){
// Reserve capacity for current snapshots so historical events cannot starve
// live decisions. The remaining slots continue draining history in order.
const fresh=rows(`SELECT id,HEX(kind),HEX(payload)
FROM acore_auth.azerothai_event_outbox
WHERE delivered_at IS NULL AND kind='player.snapshot'
AND created_at >= NOW() - INTERVAL 90 SECOND
ORDER BY id ASC LIMIT ${Math.max(1,Math.floor(EVENT_BATCH/2))}`);
const batch=rows(`SELECT id,HEX(kind),HEX(payload) const batch=rows(`SELECT id,HEX(kind),HEX(payload)
FROM acore_auth.azerothai_event_outbox FROM acore_auth.azerothai_event_outbox
WHERE delivered_at IS NULL WHERE delivered_at IS NULL
ORDER BY CASE WHEN kind IN ('player.whisper','player.chat') THEN 0 ELSE 1 END, id ${fresh.length?`AND id NOT IN (${fresh.map(row=>Number(row[0])).join(',')})`:''}
LIMIT ${EVENT_BATCH}`); 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
for(const [id,kindHex,payloadHex] of batch){ LIMIT ${Math.max(1,EVENT_BATCH-fresh.length)}`);
for(const [id,kindHex,payloadHex] of [...fresh,...batch]){
const kind=Buffer.from(kindHex||'','hex').toString('utf8'); const kind=Buffer.from(kindHex||'','hex').toString('utf8');
const payloadText=Buffer.from(payloadHex||'','hex').toString('utf8'); const payloadText=Buffer.from(payloadHex||'','hex').toString('utf8');
let payload; let payload;
@@ -116,7 +124,7 @@ async function deliverEvents(){
mysql(`UPDATE acore_auth.azerothai_event_outbox SET delivered_at=NOW(6) WHERE id=${Number(id)}`); mysql(`UPDATE acore_auth.azerothai_event_outbox SET delivered_at=NOW(6) WHERE id=${Number(id)}`);
if(EVENT_GAP_MS)await sleep(EVENT_GAP_MS); if(EVENT_GAP_MS)await sleep(EVENT_GAP_MS);
} }
return batch.length; return fresh.length+batch.length;
} }
async function deliverResults(){ async function deliverResults(){
@@ -136,6 +144,14 @@ async function deliverResults(){
function cleanup(){ function cleanup(){
mysql(` mysql(`
CREATE TABLE IF NOT EXISTS acore_auth.azerothai_snapshot_archive LIKE acore_auth.azerothai_event_outbox;
INSERT IGNORE INTO acore_auth.azerothai_snapshot_archive
SELECT * FROM acore_auth.azerothai_event_outbox
WHERE delivered_at IS NULL AND kind='player.snapshot'
AND created_at < NOW() - INTERVAL 90 SECOND;
UPDATE acore_auth.azerothai_event_outbox e
JOIN acore_auth.azerothai_snapshot_archive a ON a.id=e.id
SET e.delivered_at=NOW(6) WHERE e.delivered_at IS NULL;
UPDATE acore_auth.azerothai_event_outbox UPDATE acore_auth.azerothai_event_outbox
SET delivered_at=NOW(6) SET delivered_at=NOW(6)
WHERE delivered_at IS NULL WHERE delivered_at IS NULL
@@ -151,10 +167,10 @@ console.log(`AzerothAI ALE DB relay started: ${BASE}, poll ${POLL_MS}ms`);
while(!stopping){ while(!stopping){
try{ try{
if(Date.now()-lastCleanup>60_000){cleanup();lastCleanup=Date.now();}
await deliverEvents(); await deliverEvents();
await deliverResults(); await deliverResults();
await pullCommand(); await pullCommand();
if(Date.now()-lastCleanup>60_000){cleanup();lastCleanup=Date.now();}
await sleep(POLL_MS); await sleep(POLL_MS);
}catch(error){ }catch(error){
console.error(new Date().toISOString(),error instanceof Error?error.message:String(error)); console.error(new Date().toISOString(),error instanceof Error?error.message:String(error));
+28
View File
@@ -0,0 +1,28 @@
// Run the built worker without Wrangler's development proxy or file watcher.
import './sites-env.mjs';
import {createRequire} from 'node:module';
import path from 'node:path';
import {readdirSync} from 'node:fs';
import {unstable_getMiniflareWorkerOptions} from 'wrangler';
const require = createRequire(import.meta.url);
const wranglerRequire = createRequire(require.resolve('wrangler'));
const {Miniflare} = wranglerRequire('miniflare');
const config = path.resolve('dist/server/wrangler.json');
const {workerOptions, externalWorkers, main} = unstable_getMiniflareWorkerOptions(config);
if (!main) throw new Error('Build the application before starting the local runtime');
const root = path.dirname(main);
const modules = readdirSync(root, {recursive: true})
.filter(file => /\.(m?js)$/.test(file))
.map(file => ({type: 'ESModule', path: path.join(root, file)}));
modules.sort((a, b) => a.path === main ? -1 : b.path === main ? 1 : 0);
const runtime = new Miniflare({
host: process.env.RUNTIME_HOST || (process.argv.includes('--lan') ? '0.0.0.0' : '127.0.0.1'),
port: Number(process.env.RUNTIME_PORT || 8787),
d1Persist: path.resolve('.wrangler/state/v3/d1'),
workers: [{...workerOptions, modules, modulesRoot: root}, ...externalWorkers],
});
console.log(`AzerothAI local runtime ready: ${await runtime.ready}`);
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, async () => { await runtime.dispose(); process.exit(0); });
}
+2 -2
View File
@@ -1,6 +1,6 @@
import {spawnSync} from 'node:child_process'; import {spawnSync} from 'node:child_process';
import {writeFileSync,mkdirSync} from 'node:fs'; import {writeFileSync,mkdirSync} from 'node:fs';
const result=spawnSync(process.execPath,['node_modules/typescript/bin/tsc','core/service.ts','core/seed.ts','core/ale.ts','--outDir','.test-build','--module','commonjs','--target','es2022','--moduleResolution','node','--esModuleInterop','--skipLibCheck','--strict'],{stdio:'inherit'}); const result=spawnSync(process.execPath,['node_modules/typescript/bin/tsc','core/service.ts','core/seed.ts','core/ale.ts','core/conversation.ts','core/autonomy.ts','--types','@cloudflare/workers-types,node','--outDir','.test-build','--module','commonjs','--target','es2022','--moduleResolution','node','--esModuleInterop','--skipLibCheck','--strict'],{stdio:'inherit'});
if(result.status)process.exit(result.status); if(result.status)process.exit(result.status);
mkdirSync('.test-build',{recursive:true});writeFileSync('.test-build/package.json','{"type":"commonjs"}'); mkdirSync('.test-build',{recursive:true});writeFileSync('.test-build/package.json','{"type":"commonjs"}');
process.exit(spawnSync(process.execPath,['--test','tests/core.test.cjs','tests/hardcore.test.cjs','tests/living-world.test.cjs','tests/ale.test.cjs','tests/brains.test.cjs'],{stdio:'inherit'}).status??1); process.exit(spawnSync(process.execPath,['--test','tests/core.test.cjs','tests/hardcore.test.cjs','tests/living-world.test.cjs','tests/ale.test.cjs','tests/brains.test.cjs','tests/conversation.test.cjs','tests/autonomy.test.cjs'],{stdio:'inherit'}).status??1);
+31
View File
@@ -0,0 +1,31 @@
const {test}=require('node:test');
const assert=require('node:assert/strict');
const ale=require('../.test-build/ale-store.js');
const llm=require('../.test-build/llm/index.js');
const {runLiveAutonomyTick}=require('../.test-build/autonomy.js');
test('inference preserves intervening observations and cancels after retirement',async()=>{
const now=Date.now();
const bot={id:'hero',name:'Zaku',status:'online',tier:'hero',decisions:0,
personality:{activities:['explore']},autonomy:{managed:true},level:5,zone:'Old zone'};
let state={revision:1,bots:[bot],events:[],sequence:0,settings:{}};
const store={load:async()=>structuredClone(state),save:async(s,revision)=>{
assert.equal(revision,state.revision);state=s;
}};
ale.recentAleEvents=async()=>[{at:new Date(now).toISOString(),kind:'player.snapshot',payload:{name:'Zaku',isBot:true,level:5,zoneName:'Old zone'}}];
let queued=0,retire=false;
ale.queueAleCommand=async()=>{queued++;return {id:'intent'};};
llm.providerForBot=()=>({route:{provider:'ollama',model:'test'},provider:{decide:async()=>{
state.revision++;
state.bots[0].level=6;state.bots[0].zone='New zone';
state.bots[0].autonomy.observedAt=new Date(now+1000).toISOString();
if(retire)state.bots[0].status='retired';
return {action:'explore',goal:'Explore nearby',reason:'Curious'};
}}});
assert.equal((await runLiveAutonomyTick(store,{}, {},{now})).ran,true);
assert.equal(state.bots[0].level,6);assert.equal(state.bots[0].zone,'New zone');
assert.equal(queued,1);
retire=true;
assert.equal((await runLiveAutonomyTick(store,{}, {},{now,forceBotId:'hero'})).ran,false);
assert.equal(queued,1);
});
+75
View File
@@ -0,0 +1,75 @@
const {test}=require('node:test');
const assert=require('node:assert/strict');
const queue=require('../.test-build/conversation-store.js');
const ale=require('../.test-build/ale-store.js');
const {runConversationTick}=require('../.test-build/conversation.js');
test('conversation failure, queued delivery, confirmed memory and replay safety',async()=>{
const request={id:'request-1',speakerName:'Player',message:'Hi',channel:'whisper',receiver:'Resident',nearbyBots:[]};
const bot={id:'bot-1',name:'Resident',status:'online',autonomy:{managed:true},memories:[],personality:{},race:'Orc',role:'Warrior'};
const state={revision:0,sequence:0,events:[],bots:[bot],settings:{provider:'ollama',model:'test',brains:{enabled:false,residentMemoryLimit:5,residentMaxTokens:100}}};
const store={load:async()=>state,save:async()=>{}};
let pending=request,delivery=[],queued=0,failed='',completed=0;
queue.pendingDeliveries=async()=>delivery;
queue.leaseConversation=async()=>{const r=pending;pending=null;return r;};
queue.failConversation=async(_db,_id,error)=>{failed=error;};
queue.completeConversation=async()=>{completed++;};
queue.cleanupConversations=async()=>{};
queue.awaitDelivery=async(_db,r,botId,reply,commandId)=>{delivery=[{request:r,botId,reply,commandId,status:'pending'}];};
ale.queueAleCommand=async()=>{queued++;return {id:'command-1'};};
const originalFetch=global.fetch;
try{
await assert.rejects(runConversationTick(store,{},{}),/not configured/);
assert.match(failed,/no canned reply/);assert.equal(queued,0);assert.equal(bot.memories.length,0);
pending=request;global.fetch=async()=>({ok:false,status:503});
await assert.rejects(runConversationTick(store,{}, {LLM_URL:'http://model'}),/503/);
assert.equal(queued,0);
pending=request;global.fetch=async()=>({ok:true,json:async()=>({message:{content:'{"reply":"Hello, traveler."}'}})});
await runConversationTick(store,{}, {LLM_URL:'http://model'});
assert.equal(queued,1);assert.equal(bot.memories.length,0);assert.equal(completed,0);
await runConversationTick(store,{},{});
assert.equal(bot.memories.length,0);
delivery[0].status='completed';
await runConversationTick(store,{},{});
assert.equal(bot.memories.length,1);assert.equal(completed,1);
await runConversationTick(store,{},{});
assert.equal(bot.memories.length,1);
delivery=[{...delivery[0],request:{...request,id:'request-2'},status:'failed',error:'Bot offline'}];
await runConversationTick(store,{},{});
assert.equal(failed,'Bot offline');assert.equal(bot.memories.length,1);
}finally{global.fetch=originalFetch;}
});
test('social exchanges remember both residents only after confirmed reply and replays are harmless',async()=>{
const a={id:'a',name:'A',status:'online',autonomy:{managed:true},memories:[],relationships:[],personality:{},race:'Orc',role:'Warrior'};
const b={...a,id:'b',name:'B',memories:[],relationships:[]};
const state={revision:0,sequence:0,events:[],bots:[a,b],settings:{provider:'ollama',model:'test',brains:{enabled:false,residentMemoryLimit:5,residentMaxTokens:100}}};
const store={load:async()=>state,save:async()=>{}};
const request={id:'social-test',createdAt:new Date().toISOString(),socialTurn:0,speakerName:'B',selectedPlayer:'A',message:'Initiate',channel:'say',nearbyBots:['B']};
let pending=request,deliveries=[],followups=0,source='';
queue.pendingDeliveries=async()=>deliveries;
queue.leaseConversation=async()=>{const r=pending;pending=null;return r;};
queue.cleanupConversations=async()=>{};
queue.completeConversation=async()=>{};
queue.failConversation=async()=>{};
queue.awaitDelivery=async(_db,r,botId,reply,commandId)=>{deliveries=[{request:r,botId,reply,commandId,status:'pending'}];};
queue.queueSocialReply=async(_db,r,name,reply)=>{if(r.socialTurn===0){followups++;pending={...r,id:r.id+':reply',socialTurn:1,speakerName:name,selectedPlayer:'B',message:reply};}};
ale.queueAleCommand=async(_db,s)=>{source=s;return {id:'cmd'};};
const originalFetch=global.fetch;
global.fetch=async()=>({ok:true,json:async()=>({message:{content:'{"reply":"How is the hunt going?"}'}})});
try{
await runConversationTick(store,{}, {LLM_URL:'http://model'});
assert.match(source,/GetDistance\(peer\)>25/);
assert.match(source,/GetTeam/);
assert.equal(a.memories.length,0);
deliveries[0].status='completed';
await runConversationTick(store,{}, {LLM_URL:'http://model'});
assert.equal(followups,1);assert.equal(a.memories.length,0);
deliveries[0].status='completed';
await runConversationTick(store,{},{});
assert.equal(a.memories.length,1);assert.equal(b.memories.length,1);
assert.equal(a.relationships[0].targetId,'b');assert.equal(b.relationships[0].targetId,'a');
await runConversationTick(store,{},{});
assert.equal(a.memories.length,1);assert.equal(b.memories.length,1);assert.equal(followups,1);
}finally{global.fetch=originalFetch;}
});
+26
View File
@@ -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');
@@ -66,3 +79,16 @@ test('unverified bridge death fails safe without erasing identity',async()=>{
assert.equal(bot.death,undefined); assert.equal(bot.death,undefined);
}finally{connectors.simulation=old;} }finally{connectors.simulation=old;}
}); });
test('complete roster clears stale presence and historical events cannot restore it',async()=>{
const store=new Store();
const payload={name:'Rosterbot',isBot:true,characterGuid:123,accountId:2,className:'Death Knight',raceName:'Orc',level:55};
await applyAuthoritativeAleEvent(store,'player.snapshot',payload);
await applyAuthoritativeAleEvent(store,'player.snapshot',{roster:true,observedAt:100,characterGuids:[123]});
assert.equal(store.state.bots.filter(b=>b.status==='online').length,1);
await applyAuthoritativeAleEvent(store,'player.snapshot',{roster:true,observedAt:101,characterGuids:[]});
for(const kind of ['player.login','player.snapshot','player.zone'])await applyAuthoritativeAleEvent(store,kind,payload);
assert.equal(store.state.bots.filter(b=>b.status==='online').length,0);
await applyAuthoritativeAleEvent(store,'player.snapshot',{roster:true,observedAt:100,characterGuids:[123]});
assert.equal(store.state.bots.filter(b=>b.status==='online').length,0);
});
+133
View File
@@ -0,0 +1,133 @@
"""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:GetClass() return self.class or 1 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)
""")
dk=LuaRuntime()
dk.execute(setup)
dk.execute("""
players={};rows={{201,'AllianceDK',2,0,'reserve',1},{202,'HordeDK',2,0,'reserve',2}}
local a=player(201,'AllianceDK',2,55,false);a.class=6;a.race=1
local h=player(202,'HordeDK',2,55,false);h.class=6
""")
dk.execute((Path(__file__).parents[1] / 'ale/azerothai_lifecycle.lua').read_text())
dk.execute("""
assert(players.AllianceDK and players.HordeDK)
local entries=0
for _,q in pairs(writes) do if string.find(q,'entry_level=55',1,true) then entries=entries+1 end end
assert(entries==2,'Death Knights must be audited at level 55')
events[8](8,nil,players.HordeDK);tick();assert(not players.HordeDK and deaths==1)
""")
print('PASS: retirement, protection, level-1 entry, replay safety, and 25/25 faction balancing')