Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee8161eca2 | ||
|
|
ce6b38f3bb | ||
|
|
360f164905 | ||
|
|
77f6b56c2f | ||
|
|
bc50395221 | ||
|
|
c5f84a7221 | ||
|
|
86d57ec9ae |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,37 @@ 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))
|
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 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 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)
|
||||||
return emitted
|
return emitted
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -254,7 +291,7 @@ RegisterPlayerEvent(27, function(event, player, newZone, newArea)
|
|||||||
local payload=player_payload(player)
|
local payload=player_payload(player)
|
||||||
payload.zone=newZone
|
payload.zone=newZone
|
||||||
payload.area=newArea
|
payload.area=newArea
|
||||||
payload.zoneName=GetAreaName(newZone) or tostring(newZone)
|
payload.zoneName=area_name(newZone)
|
||||||
emit("player.zone",payload)
|
emit("player.zone",payload)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,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")
|
||||||
@@ -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
@@ -41,7 +41,7 @@ export default function Home(){
|
|||||||
<div className="page-heading"><div><div className="eyebrow">{page==='GM Console'?'THE WORLD IS YOUR SANDBOX':'AZEROTHAI CONTROL PLANE'}</div><h1>{page==='Dashboard'?'Your realm, alive.':page}</h1><p className="subtitle">{({'Dashboard':'The people, personalities, and stories that make your world.','World Monitor':'Watch Azeroth breathe: population, activity, systems, and the lives unfolding right now.','Realm Director':'Shape the adventure around you. Let your characters bring it to life.','World & Housing':'Homes, routines, simulation bands, and believable population flow.','Bots':'Give every character a purpose, then let their story unfold.','Bot Recipes':'Same model. Different personalities. A world of possibilities.','Memory & Bonds':'A shared history makes the next encounter matter.','Event Stream':'Follow the decisions and changes happening across your realm.','GM Console':'Pull up a chair. Put on the blue robe. Run your world.','Server & Models':'Connect the game engine and the minds behind your characters.'} as any)[page]}</p></div><div className="actions">{page==='Bot Recipes'?<Button onClick={()=>{setRecipe(null);setRecipeOpen(true)}}><Plus size={16}/>Create recipe</Button>:<><Button variant="outline" disabled={busy} onClick={()=>void refresh()}><RefreshCw size={15}/>Refresh</Button><Button onClick={()=>setCreateOpen(true)}><Plus size={16}/>Create bot</Button></>}</div></div>
|
<div className="page-heading"><div><div className="eyebrow">{page==='GM Console'?'THE WORLD IS YOUR SANDBOX':'AZEROTHAI CONTROL PLANE'}</div><h1>{page==='Dashboard'?'Your realm, alive.':page}</h1><p className="subtitle">{({'Dashboard':'The people, personalities, and stories that make your world.','World Monitor':'Watch Azeroth breathe: population, activity, systems, and the lives unfolding right now.','Realm Director':'Shape the adventure around you. Let your characters bring it to life.','World & Housing':'Homes, routines, simulation bands, and believable population flow.','Bots':'Give every character a purpose, then let their story unfold.','Bot Recipes':'Same model. Different personalities. A world of possibilities.','Memory & Bonds':'A shared history makes the next encounter matter.','Event Stream':'Follow the decisions and changes happening across your realm.','GM Console':'Pull up a chair. Put on the blue robe. Run your world.','Server & Models':'Connect the game engine and the minds behind your characters.'} as any)[page]}</p></div><div className="actions">{page==='Bot Recipes'?<Button onClick={()=>{setRecipe(null);setRecipeOpen(true)}}><Plus size={16}/>Create recipe</Button>:<><Button variant="outline" disabled={busy} onClick={()=>void refresh()}><RefreshCw size={15}/>Refresh</Button><Button onClick={()=>setCreateOpen(true)}><Plus size={16}/>Create bot</Button></>}</div></div>
|
||||||
{error&&<div className="notice" role="alert">{error} <Button variant="outline" onClick={()=>void refresh()}>Retry</Button></div>}
|
{error&&<div className="notice" role="alert">{error} <Button variant="outline" onClick={()=>void refresh()}>Retry</Button></div>}
|
||||||
{!state?<div className="stats">{[1,2,3,4].map(n=><Skeleton key={n} className="h-32"/>)}</div>:<>
|
{!state?<div className="stats">{[1,2,3,4].map(n=><Skeleton key={n} className="h-32"/>)}</div>:<>
|
||||||
{page==='Dashboard'&&<><div className="stats">{[[Users,'Active bots',state.bots.filter(b=>b.status==='online').length,'Online in the simulation'],[BookOpen,'Bot recipes',state.recipes.length,'Distinct ways to experience the world'],[Brain,'Memories',state.bots.reduce((n,b)=>n+b.memories.length,0),'Stories carried into future decisions'],[Network,'Relationships',state.bots.reduce((n,b)=>n+b.relationships.length,0),'Directed bonds between characters']].map(([Icon,label,value,note]:any)=><div className="panel stat" key={label}><div className="stat-label">{label}<Icon/></div><div className="stat-value">{value}</div><small>{label==='Active bots'&&state.settings.connector!=='simulation'?'Acknowledged by the game bridge':note}</small></div>)}</div><section className="panel director-strip"><div className="director-content"><Compass className="director-icon"/><div><div className="eyebrow">YOUR NEXT ADVENTURE</div><h2>{state.director.experience}</h2><p>{state.director.zone} · {state.director.brief}</p></div></div><Button variant="outline" onClick={()=>nav('Realm Director')}>Shape the experience <ArrowRight size={15}/></Button></section><div className="dashboard-grid"><Panel title="Your characters" icon={Users} action={<Button variant="ghost" size="sm" onClick={()=>nav('Bots')}>Manage bots <ArrowRight size={14}/></Button>}>{botTable(state.bots.slice(0,5))}</Panel><Panel title="Realm pulse" icon={Radio}><div className="panel-body">{Array.from(new Set(state.bots.filter(b=>b.status==='online').map(b=>b.zone))).map(zone=>{const n=state.bots.filter(b=>b.status==='online'&&b.zone===zone).length;return <div className="zone-row" key={zone}><span className="zone-label">{zone}</span><span className="bar"><i style={{width:`${n/Math.max(1,state.bots.filter(b=>b.status==='online').length)*100}%`}}/></span><span>{n}</span></div>})}<p className="fineprint">Bot locations from the selected connector. Simulation locations are illustrative.</p><div className="engine-box" style={{marginTop:25,marginBottom:0}}><Brain size={23}/><div><h3>{state.settings.provider==='deterministic'?'Personality simulator':state.settings.model}</h3><p>{state.settings.provider==='deterministic'?'No model required. Try a decision from any online bot.':'Local model selected. Each decision includes identity and memories.'}</p></div></div></div></Panel><Panel title="Recent activity" icon={Activity} action={<Button variant="ghost" size="sm" onClick={()=>nav('Event Stream')}>View stream <ArrowRight size={14}/></Button>}>{<Events events={state.events.slice(-5)}/>}</Panel><Panel title="The Game Master’s chair" icon={Shield}><div className="panel-body"><p className="list-muted">Set the scene. Announce a gathering. Move a character to the next chapter of their story.</p><div className="gm-preview"><span><GM> AzerothAI</span><p>Welcome to your world.</p><code>.announce An adventure awaits...</code></div><Button variant="outline" onClick={()=>nav('GM Console')}>Open GM console <Terminal size={15}/></Button></div></Panel></div></>}
|
{page==='Dashboard'&&<><div className="stats">{[[Users,'Active bots',state.bots.filter(b=>b.status==='online'&&(state.settings.connector==='simulation'||b.autonomy?.managed)).length,'Online in the simulation'],[BookOpen,'Bot recipes',state.recipes.length,'Distinct ways to experience the world'],[Brain,'Memories',state.bots.reduce((n,b)=>n+b.memories.length,0),'Stories carried into future decisions'],[Network,'Relationships',state.bots.reduce((n,b)=>n+b.relationships.length,0),'Directed bonds between characters']].map(([Icon,label,value,note]:any)=><div className="panel stat" key={label}><div className="stat-label">{label}<Icon/></div><div className="stat-value">{value}</div><small>{label==='Active bots'&&state.settings.connector!=='simulation'?'Acknowledged by the game bridge':note}</small></div>)}</div><section className="panel director-strip"><div className="director-content"><Compass className="director-icon"/><div><div className="eyebrow">YOUR NEXT ADVENTURE</div><h2>{state.director.experience}</h2><p>{state.director.zone} · {state.director.brief}</p></div></div><Button variant="outline" onClick={()=>nav('Realm Director')}>Shape the experience <ArrowRight size={15}/></Button></section><div className="dashboard-grid"><Panel title="Your characters" icon={Users} action={<Button variant="ghost" size="sm" onClick={()=>nav('Bots')}>Manage bots <ArrowRight size={14}/></Button>}>{botTable(state.bots.slice(0,5))}</Panel><Panel title="Realm pulse" icon={Radio}><div className="panel-body">{Array.from(new Set(state.bots.filter(b=>b.status==='online').map(b=>b.zone))).map(zone=>{const n=state.bots.filter(b=>b.status==='online'&&b.zone===zone).length;return <div className="zone-row" key={zone}><span className="zone-label">{zone}</span><span className="bar"><i style={{width:`${n/Math.max(1,state.bots.filter(b=>b.status==='online').length)*100}%`}}/></span><span>{n}</span></div>})}<p className="fineprint">Bot locations from the selected connector. Simulation locations are illustrative.</p><div className="engine-box" style={{marginTop:25,marginBottom:0}}><Brain size={23}/><div><h3>{state.settings.provider==='deterministic'?'Personality simulator':state.settings.model}</h3><p>{state.settings.provider==='deterministic'?'No model required. Try a decision from any online bot.':'Local model selected. Each decision includes identity and memories.'}</p></div></div></div></Panel><Panel title="Recent activity" icon={Activity} action={<Button variant="ghost" size="sm" onClick={()=>nav('Event Stream')}>View stream <ArrowRight size={14}/></Button>}>{<Events events={state.events.slice(-5)}/>}</Panel><Panel title="The Game Master’s chair" icon={Shield}><div className="panel-body"><p className="list-muted">Set the scene. Announce a gathering. Move a character to the next chapter of their story.</p><div className="gm-preview"><span><GM> AzerothAI</span><p>Welcome to your world.</p><code>.announce An adventure awaits...</code></div><Button variant="outline" onClick={()=>nav('GM Console')}>Open GM console <Terminal size={15}/></Button></div></Panel></div></>}
|
||||||
{page==='World Monitor'&&<WorldMonitor state={state} monitor={monitor} capabilities={capabilities} openBot={setBotId}/>}
|
{page==='World Monitor'&&<WorldMonitor state={state} monitor={monitor} capabilities={capabilities} openBot={setBotId}/>}
|
||||||
{page==='Bots'&&<><div className="toolbar"><Input aria-label="Search bots" placeholder="Search name, class, or location…" value={query} onChange={e=>setQuery(e.target.value)}/><Choice label="Bot status" value={filter} onChange={setFilter} items={options(['all','online','paused','offline','retired'])}/><small>{visible.length} characters</small></div><Panel title="Bot lifecycle" icon={Users}>{visible.length?botTable(visible):<div className="empty-note">No matching bots. Create a character or adjust your filters.</div>}</Panel></>}
|
{page==='Bots'&&<><div className="toolbar"><Input aria-label="Search bots" placeholder="Search name, class, or location…" value={query} onChange={e=>setQuery(e.target.value)}/><Choice label="Bot status" value={filter} onChange={setFilter} items={options(['all','online','paused','offline','retired'])}/><small>{visible.length} characters</small></div><Panel title="Bot lifecycle" icon={Users}>{visible.length?botTable(visible):<div className="empty-note">No matching bots. Create a character or adjust your filters.</div>}</Panel></>}
|
||||||
{page==='Bot Recipes'&&<><div className="notice">Recipes are versioned. Saving a recipe preserves existing bots’ personalities until you explicitly apply the new version.</div><div className="recipe-grid">{state.recipes.map((r,i)=><button key={r.id} className="panel recipe-card" onClick={()=>{setRecipe(r);setRecipeOpen(true)}}><div style={{display:'flex',justifyContent:'space-between'}}><span className="recipe-symbol">{i%2?<Compass size={23}/>:<BookOpen size={23}/>}</span><small>VERSION {r.version}</small></div><h3>{r.name}</h3><p>{r.description}</p><div className="tags">{r.tags.map(t=><span className="tag" key={t}>{t}</span>)}</div><div style={{display:'flex',justifyContent:'space-between',marginTop:21}}><small>{state.bots.filter(b=>b.recipeId===r.id).length} bot identities</small><small style={{color:'#9abef7'}}>Edit recipe →</small></div></button>)}</div></>}
|
{page==='Bot Recipes'&&<><div className="notice">Recipes are versioned. Saving a recipe preserves existing bots’ personalities until you explicitly apply the new version.</div><div className="recipe-grid">{state.recipes.map((r,i)=><button key={r.id} className="panel recipe-card" onClick={()=>{setRecipe(r);setRecipeOpen(true)}}><div style={{display:'flex',justifyContent:'space-between'}}><span className="recipe-symbol">{i%2?<Compass size={23}/>:<BookOpen size={23}/>}</span><small>VERSION {r.version}</small></div><h3>{r.name}</h3><p>{r.description}</p><div className="tags">{r.tags.map(t=><span className="tag" key={t}>{t}</span>)}</div><div style={{display:'flex',justifyContent:'space-between',marginTop:21}}><small>{state.bots.filter(b=>b.recipeId===r.id).length} bot identities</small><small style={{color:'#9abef7'}}>Edit recipe →</small></div></button>)}</div></>}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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}>;}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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); });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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')
|
||||||
Reference in New Issue
Block a user