14 Commits
Author SHA1 Message Date
Codex 9ec0ef2b1b Merge tested realm lifecycle, community, recovery, and visitor dashboard into main
Core tests / core (push) Successful in 1m30s
2026-09-22 20:18:00 -04:00
Codex 210b715a6f Share admin dashboard views through isolated read-only visitor access 2026-09-22 20:18:00 -04:00
Codex 7e19d82a02 Add isolated read-only spectator monitor for public sharing 2026-09-22 19:50:05 -04:00
Codex 8e96c7ff47 Reconcile delayed hardcore deaths with native population registry 2026-09-22 18:15:35 -04:00
Codex 46935c791e Add social feed, cooperative parties, guild goals, and operations recovery 2026-09-22 17:43:07 -04:00
Codex 5bee808492 Prefer stationary social pairs and deliver roster before individual snapshots 2026-09-22 17:13:19 -04:00
Codex b0a661f425 Document autonomous conversation bounds and memory storage 2026-09-22 17:12:21 -04:00
Codex ee8161eca2 Enable bounded proximity conversations and confirmed social memories 2026-09-22 17:10:15 -04:00
Codex ce6b38f3bb Reconcile dashboard online presence against complete live roster 2026-09-22 16:30:45 -04:00
Codex 360f164905 Allow level-55 Death Knights within race-based faction limits 2026-09-22 16:20:47 -04:00
Codex 77f6b56c2f Enforce hardcore retirement with level-one faction-balanced replacements 2026-09-22 15:55:41 -04:00
Codex bc50395221 Restore live autonomy with direct runtime and fresh snapshot capacity 2026-09-22 15:07:24 -04:00
Richard Clark c5f84a7221 Disable Actions dependency caching in all workflows 2026-09-22 14:27:17 -04:00
Richard Clark 86d57ec9ae Fix conversation failure handling and confirmed delivery memories 2026-09-22 14:17:06 -04:00
41 changed files with 1932 additions and 74 deletions
-1
View File
@@ -18,6 +18,5 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: node scripts/test-core.mjs - run: node scripts/test-core.mjs
-2
View File
@@ -18,7 +18,6 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- name: Core and simulation tests - name: Core and simulation tests
run: node scripts/test-core.mjs run: node scripts/test-core.mjs
@@ -62,7 +61,6 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- name: Install Ollama - name: Install Ollama
run: curl -fsSL https://ollama.com/install.sh | sh run: curl -fsSL https://ollama.com/install.sh | sh
-1
View File
@@ -31,7 +31,6 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
+94 -4
View File
@@ -47,6 +47,11 @@ local function json_value(value)
if value == nil then return "null" end if value == nil then return "null" end
if t == "boolean" then return value and "true" or "false" end if t == "boolean" then return value and "true" or "false" end
if t == "number" then return tostring(value) end if t == "number" then return tostring(value) end
if t == "table" then
local parts={}
for _,item in ipairs(value) do parts[#parts+1]=json_value(item) end
return "["..table.concat(parts,",").."]"
end
return '"' .. escape_json(value) .. '"' return '"' .. escape_json(value) .. '"'
end end
@@ -85,6 +90,11 @@ local function emit(kind, payload)
AuthDBExecute(sql) AuthDBExecute(sql)
end end
local function area_name(id)
local ok,name=pcall(GetAreaName,id)
return ok and name or tostring(id)
end
local function player_payload(player) local function player_payload(player)
if not player then return {} end if not player then return {} end
local isBot = is_playerbot(player) local isBot = is_playerbot(player)
@@ -96,12 +106,15 @@ 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()),
className=player:GetClassAsString() or tostring(player:GetClass()), className=player:GetClassAsString() or tostring(player:GetClass()),
healthPct=player:GetHealthPct(), healthPct=player:GetHealthPct(),
observedAt=os.time(),team=player:GetTeam(),x=player:GetX(),y=player:GetY(),
guildId=player:GetGuildId(),guildName=player:GetGuildName() or "",guildRank=player:GetGuildRank(),
inCombat=player:IsInCombat(),
isBot=isBot isBot=isBot
} }
end end
@@ -128,14 +141,31 @@ local function nearby_bot_names(player)
return table.concat(names,",") return table.concat(names,",")
end end
local function world_payload(player)
local p=player_payload(player)
local g=player:GetGroup()
p.groupId=g and tostring(g:GetGUID()) or ""
local members={}
if g then for _,m in pairs(g:GetMembers()) do members[#members+1]=m:GetName() end end
p.groupNames=table.concat(members,",")
p.nearbyBots=nearby_bot_names(player)
return p
end
function AzerothAIEmitPlayerSnapshot(name) function AzerothAIEmitPlayerSnapshot(name)
if not name or name == "" then return 0 end if not name or name == "" then return 0 end
local ok,player=pcall(function() return GetPlayerByName(name) end) local ok,player=pcall(function() return GetPlayerByName(name) end)
if not ok or not player or not is_playerbot(player) then return 0 end if not ok or not player or not is_playerbot(player) then return 0 end
emit("player.snapshot",player_payload(player)) emit("player.snapshot",world_payload(player))
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 +202,39 @@ CreateLuaEvent(function() poll() end, POLL_MS, 0)
-- Keep AzerothAI grounded in real online Playerbots even when no human is logged in. -- Keep AzerothAI grounded in real online Playerbots even when no human is logged in.
-- Expose a safe global refresh hook too, so the autonomy worker can explicitly -- Expose a safe global refresh hook too, so the autonomy worker can explicitly
-- request a fresh authoritative snapshot if the periodic timer ever stalls. -- request a fresh authoritative snapshot if the periodic timer ever stalls.
local socialLast={}
function AzerothAIEmitSnapshots() function AzerothAIEmitSnapshots()
local ok,players=pcall(function() return GetPlayersInWorld() end) local ok,players=pcall(function() return GetPlayersInWorld() end)
if not ok or type(players)~="table" then return 0 end if not ok or type(players)~="table" then return 0 end
local emitted=0 local emitted=0
local guids={}
for _,player in pairs(players) do for _,player in pairs(players) do
if is_playerbot(player) then if is_playerbot(player) then
emit("player.snapshot",player_payload(player)) guids[#guids+1]=player:GetGUIDLow()
emitted=emitted+1 emitted=emitted+1
end end
end end
-- Use snapshot transport so roster reconciliation shares fresh-event priority.
local roster={roster=true,observedAt=os.time(),characterGuids=guids}
local ordered={}
for _,p in pairs(players) do if is_playerbot(p) then ordered[#ordered+1]=p end end
table.sort(ordered,function(a,b) return (socialLast[a:GetGUIDLow()] or 0)<(socialLast[b:GetGUIDLow()] or 0) end)
for _,p in ipairs(ordered) do
if not p:IsDead() and not p:IsInCombat() and not p:IsMoving() and os.time()-(socialLast[p:GetGUIDLow()] or 0)>600 then
for _,q in pairs(p:GetPlayersInRange(25) or {}) do
if q:GetGUIDLow()~=p:GetGUIDLow() and is_playerbot(q) and not q:IsDead() and not q:IsInCombat() and not q:IsMoving() and p:GetDistance(q)<=15 and p:GetTeam()==q:GetTeam() and p:GetPhaseMask()==q:GetPhaseMask() and os.time()-(socialLast[q:GetGUIDLow()] or 0)>600 then
roster.socialA=p:GetName();roster.socialB=q:GetName();roster.zoneName=area_name(p:GetZoneId())
socialLast[p:GetGUIDLow()]=os.time();socialLast[q:GetGUIDLow()]=os.time()
break
end
end
end
if roster.socialA then break end
end
emit("player.snapshot",roster)
for _,player in pairs(players) do
if is_playerbot(player) then emit("player.snapshot",world_payload(player)) end
end
return emitted return emitted
end end
@@ -254,7 +307,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)
@@ -343,3 +396,40 @@ end
emit("bridge.ready",{pollMs=POLL_MS,protectedCharacter=PROTECTED}) emit("bridge.ready",{pollMs=POLL_MS,protectedCharacter=PROTECTED})
PrintInfo("[AzerothAI] ALE bridge loaded; durable DB command polling every " .. tostring(POLL_MS) .. " ms") PrintInfo("[AzerothAI] ALE bridge loaded; durable DB command polling every " .. tostring(POLL_MS) .. " ms")
local completedQuests={}
local helpedAt={}
local function cooperative_event(a,b,kind,key,quest)
emit("player.snapshot",{cooperation=kind,evidenceId=key,characterGuid=a:GetGUIDLow(),peerGuid=b:GetGUIDLow(),observedAt=os.time(),questId=quest or 0})
end
RegisterPlayerEvent(54,function(event,player,quest)
if not is_playerbot(player) then return end
local q=quest:GetId()
completedQuests[player:GetGUIDLow()..":"..q]=os.time()
local g=player:GetGroup();if not g then return end
for _,other in pairs(g:GetMembers()) do
if other~=player and is_playerbot(other) and player:GetDistance(other)<=35 then
local stamp=completedQuests[other:GetGUIDLow()..":"..q]
if stamp and os.time()-stamp<300 then
local a,b=player:GetGUIDLow(),other:GetGUIDLow()
cooperative_event(player,other,"quest","quest:"..math.min(a,b)..":"..math.max(a,b)..":"..q..":"..math.floor(os.time()/86400),q)
end
end
end
end)
RegisterPlayerEvent(7,function(event,player,killed)
if not is_playerbot(player) then return end
local g=player:GetGroup();if not g then return end
for _,other in pairs(g:GetMembers()) do
if other~=player and is_playerbot(other) and not other:IsDead() and other:IsInCombat() and other:GetVictim() and other:GetVictim():GetGUID()==killed:GetGUID() and player:GetDistance(other)<=35 then
local a,b=player:GetGUIDLow(),other:GetGUIDLow();local key=math.min(a,b)..":"..math.max(a,b)
if os.time()-(helpedAt[key] or 0)>300 then
helpedAt[key]=os.time();cooperative_event(player,other,"assist","assist:"..key..":"..os.time())
end
end
end
end)
CreateLuaEvent(function()
for k,t in pairs(completedQuests) do if os.time()-t>300 then completedQuests[k]=nil end end
for k,t in pairs(helpedAt) do if os.time()-t>600 then helpedAt[k]=nil end end
end,60000,0)
+206
View File
@@ -0,0 +1,206 @@
-- 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 reconciledAt = 0
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
-- The dashboard may receive a verified death from an older transport backlog
-- after this process seeded its registry. Import terminal states only; never
-- overwrite a locally observed death with an older active database row.
if os.time()-reconciledAt>=10 then
local q=AuthDBQuery("SELECT guid,name,account_id FROM azerothai_lifecycle WHERE state='dead' AND protected=0 AND LOWER(name)<>'zaku'")
if q then repeat
local row=residents[q:GetUInt32(0)]
if row and not row.protected and row.name==q:GetString(1) and row.account==q:GetUInt32(2) then row.state="dead" end
until not q:NextRow() end
reconciledAt=os.time()
end
local mgr=GetRandomPlayerbotMgr()
-- Disallow every retired identity before the native manager fills vacancies.
for guid,row in pairs(residents) do
if (row.state=="dead" or row.state=="blocked") and not row.protected and string.lower(row.name)~="zaku" then
local player=GetPlayerByName(row.name)
local removed=false
if player and player:GetGUIDLow()==guid and player:GetAccountId()==row.account then
mgr:Remove(player)
removed=true
end
-- Remove clears the manager's event cache, so set the login veto afterward.
if removed or not vetoAt[guid] or os.time()-vetoAt[guid]>300 then
mgr:SetValue(guid,"add",0)
mgr:SetValue(guid,"logout",1,"AzerothAI permanent retirement")
vetoAt[guid]=os.time()
end
end
end
local players=GetPlayersInWorld() or {}
local counts={0,0}
local online={}
local returning={}
for _,player in pairs(players) do
local guid=player:GetGUIDLow()
local row=residents[guid]
if row and row.state=="active" and row.account==player:GetAccountId() and row.name==player:GetName() and not player:IsDead() then
returning[#returning+1]={player=player,row=row,guid=guid}
end
end
table.sort(returning,function(a,b)
local ap=a.row.protected or string.lower(a.row.name)=="zaku"
local bp=b.row.protected or string.lower(b.row.name)=="zaku"
if ap~=bp then return ap end
return a.guid<b.guid
end)
for _,item in ipairs(returning) do
local team=faction(item.row)
if team then
if counts[team]<TARGET_PER_FACTION or item.row.protected or string.lower(item.row.name)=="zaku" then
counts[team]=counts[team]+1
online[item.guid]=true
else
-- Returning residents keep their identity and progress while held offline.
mgr:Remove(item.player)
mgr:SetValue(item.guid,"logout",1,"AzerothAI faction capacity")
gates[item.guid]=false
gateAt[item.guid]=os.time()
end
end
end
players=GetPlayersInWorld() or {}
for _,player in pairs(players) do
local row,guid=identity(player)
if row and row.state~="dead" and row.state~="blocked" then
if player:IsDead() then
recordDeath(player)
elseif row.state=="reserve" then
if (arrivals[guid] or player:GetLevel())~=startingLevel(player) then
row.state="blocked"
AuthDBExecute("UPDATE azerothai_lifecycle SET state='blocked' WHERE guid="..guid.." AND state='reserve'")
PrintError("[AzerothAI] Replacement did not start at its class starting level: "..row.name)
elseif faction(row) and counts[faction(row)]<TARGET_PER_FACTION then
counts[faction(row)]=counts[faction(row)]+1
row.state="active"
online[guid]=true
AuthDBExecute("UPDATE azerothai_lifecycle SET state='active',entry_level="..startingLevel(player)..",activated_at=COALESCE(activated_at,NOW()) WHERE guid="..guid.." AND state='reserve'")
if AzerothAIEmitPlayerSnapshot then AzerothAIEmitPlayerSnapshot(row.name) end
PrintInfo("[AzerothAI] New resident entered at class starting level: "..row.name)
else
-- A login already in flight must not push its faction over the target.
mgr:Remove(player)
mgr:SetValue(guid,"logout",1,"AzerothAI faction capacity")
gates[guid]=false
gateAt[guid]=os.time()
end
end
end
end
-- Open only enough fresh identities to fill each faction's actual deficit.
-- Persisted login vetoes keep the native random selector inside these slots.
local slots={math.max(0,TARGET_PER_FACTION-counts[1]),math.max(0,TARGET_PER_FACTION-counts[2])}
local candidates={}
for guid,row in pairs(residents) do if (row.state=="reserve" or row.state=="active") and not online[guid] and not row.protected and string.lower(row.name)~="zaku" then candidates[#candidates+1]=guid end end
table.sort(candidates,function(a,b)
if residents[a].state~=residents[b].state then return residents[a].state=="active" end
return a<b
end)
for _,guid in ipairs(candidates) do
local team=faction(residents[guid])
local allowed=team~=nil and slots[team]>0
if allowed then slots[team]=slots[team]-1 end
if gates[guid]~=allowed or not gateAt[guid] or os.time()-gateAt[guid]>300 then
mgr:SetValue(guid,"logout",allowed and 0 or 1,"AzerothAI faction capacity")
gates[guid]=allowed
gateAt[guid]=os.time()
end
if not allowed and mgr:GetValue(guid,"add")~=0 then mgr:SetValue(guid,"add",0) end
end
end
-- Record death synchronously in memory; logout happens outside the death hook.
RegisterPlayerEvent(8,function(event,killer,killed) recordDeath(killed) end)
RegisterPlayerEvent(6,function(event,killer,killed) recordDeath(killed) end)
RegisterPlayerEvent(3,function(event,player)
local row,guid=identity(player)
if row and row.state=="reserve" then arrivals[guid]=player:GetLevel() end
end)
CreateLuaEvent(function()
local ok,err=pcall(enforce)
if not ok then PrintError("[AzerothAI] Lifecycle enforcement failed: "..tostring(err)) end
end,1000,0)
reload()
enforce()
PrintInfo("[AzerothAI] Persistent hardcore retirement and reserve replacement enabled")
+4 -1
View File
@@ -2,7 +2,7 @@ import { env } from 'cloudflare:workers';
import { z } from 'zod'; import { z } from 'zod';
import { requireAleBridge } from '@/core/ale-auth'; import { requireAleBridge } from '@/core/ale-auth';
import { recordAleEvent } from '@/core/ale-store'; import { recordAleEvent } from '@/core/ale-store';
import { queueConversationEvent } from '@/core/conversation-store'; import { queueSocialEncounter,queueConversationEvent } from '@/core/conversation-store';
import { D1Store } from '@/core/store'; import { D1Store } from '@/core/store';
import { applyAuthoritativeAleEvent,execute } from '@/core/service'; import { applyAuthoritativeAleEvent,execute } from '@/core/service';
import type { RuntimeConfig } from '@/core/types'; import type { RuntimeConfig } from '@/core/types';
@@ -25,6 +25,7 @@ const STATEFUL_EVENTS=new Set([
'player.achievement', 'player.achievement',
'player.close-call', 'player.close-call',
'player.killed-by-creature', 'player.killed-by-creature',
'player.permanent-death',
'player.repop', 'player.repop',
'player.resurrect' 'player.resurrect'
]); ]);
@@ -41,6 +42,8 @@ export async function POST(request:Request){
await queueConversationEvent(env.DB!,body.kind,body.payload); await queueConversationEvent(env.DB!,body.kind,body.payload);
} }
if(body.kind==='player.snapshot'&&body.payload.roster===true)await queueSocialEncounter(env.DB!,body.payload);
const store=new D1Store(); const store=new D1Store();
if(STATEFUL_EVENTS.has(body.kind)){ if(STATEFUL_EVENTS.has(body.kind)){
await applyAuthoritativeAleEvent(store,body.kind,body.payload); await applyAuthoritativeAleEvent(store,body.kind,body.payload);
+25
View File
@@ -0,0 +1,25 @@
import {env} from 'cloudflare:workers';
import {z} from 'zod';
import {D1Store} from '@/core/store';
import type {RuntimeConfig} from '@/core/types';
const sample=z.object({host:z.enum(['ubuntu','windows']),at:z.number().finite(),cpuPercent:z.number().min(0).max(100),memoryTotalMB:z.number().nonnegative(),memoryAvailableMB:z.number().nonnegative(),diskFreeGB:z.number().nonnegative(),databaseMB:z.number().nonnegative().optional(),logsMB:z.number().nonnegative().optional(),modelAvailable:z.boolean().optional(),runtimeService:z.string().max(40).optional(),maintenance:z.string().max(500).optional()}).strict();
async function ensure(){await env.DB!.prepare('CREATE TABLE IF NOT EXISTS operations_samples (host TEXT PRIMARY KEY,at INTEGER,payload TEXT)').run();}
export async function GET(){
await ensure();const rows=await env.DB!.prepare('SELECT payload FROM operations_samples').all<{payload:string}>();
const s=await new D1Store().load(),now=Date.now()/1000;
const stuck=s.bots.filter(b=>b.status==='online'&&b.world&&now-b.world.at<120&&!b.world.inCombat&&b.world.stationarySince&&now-b.world.stationarySince>600&&['quest','explore'].includes(b.lastDecision?.action??'')).map(b=>({name:b.name,minutes:Math.floor((now-b.world!.stationarySince!)/60),zone:b.zone}));
return Response.json({samples:rows.results.map(r=>JSON.parse(r.payload)),stuck,retentionDays:s.opsRetentionDays??7,rosterAt:s.aleRosterAt??null},{headers:{'Cache-Control':'no-store'}});
}
export async function POST(request:Request){
const token=(env as unknown as RuntimeConfig).AUTONOMY_TOKEN;
if(!token||request.headers.get('authorization')!==`Bearer ${token}`)return Response.json({error:'Unauthorized'},{status:401});
try{
const raw=await request.text();if(raw.length>4000)throw new Error('Sample too large');const body=sample.parse(JSON.parse(raw));
if(Math.abs(Date.now()/1000-body.at)>180)throw new Error('Stale telemetry');
await ensure();await env.DB!.prepare('INSERT INTO operations_samples VALUES (?,?,?) ON CONFLICT(host) DO UPDATE SET at=excluded.at,payload=excluded.payload WHERE excluded.at>operations_samples.at').bind(body.host,body.at,JSON.stringify(body)).run();
// Only terminal commands are eligible; awaiting receipts and character state survive.
const s=await new D1Store().load();const before=new Date(Date.now()-(s.opsRetentionDays??7)*86400_000).toISOString();
if(body.host==='ubuntu')await env.DB!.prepare("DELETE FROM ale_commands WHERE status IN ('completed','failed') AND completed_at<? AND id NOT IN (SELECT command_id FROM community_actions WHERE status='pending') AND NOT EXISTS (SELECT 1 FROM conversation_requests c WHERE c.status='awaiting_delivery' AND instr(c.result,ale_commands.id)>0)").bind(before).run();
return Response.json({ok:true});
}catch(e){return Response.json({error:e instanceof Error?e.message:'Invalid telemetry'},{status:400});}
}
+23
View File
@@ -0,0 +1,23 @@
'use client';
import {useEffect,useState} from 'react';
import type {State} from '@/core/types';
import {Button} from '@/components/ui/button';
export function CommunityPanel({state,act,busy}:{state:State;act:(a:any)=>Promise<any>;busy:boolean}){
const [filter,setFilter]=useState('all');const c=state.community;
const parties=Object.entries(state.bots.filter(b=>b.status==='online'&&b.world?.groupId).reduce<Record<string,State['bots']>>((groups,b)=>{(groups[b.world!.groupId]??=[]).push(b);return groups;},{}));
return <>
<section className="panel"><div className="panel-head"><h2>Cooperative play</h2></div><div className="panel-body"><p>Maximum 50 residents. Group and guild changes appear as confirmed only after the game accepts them.</p><div className="actions mt-5">
<Button disabled={busy} variant="outline" onClick={()=>act({type:'communityPolicy',partiesEnabled:!(c?.partiesEnabled??true),guildsEnabled:c?.guildsEnabled??true})}>Automatic parties: {c?.partiesEnabled===false?'off':'on'}</Button>
<Button disabled={busy} variant="outline" onClick={()=>act({type:'communityPolicy',partiesEnabled:c?.partiesEnabled??true,guildsEnabled:!(c?.guildsEnabled??true)})}>Guild recruitment: {c?.guildsEnabled===false?'off':'on'}</Button>
</div></div></section>
<div className="section-grid mt-5"><section className="panel"><div className="panel-head"><h2>Live parties</h2></div><div className="panel-body">{parties.length?parties.map(([id,bots])=><div className="memory" key={id}><strong>{bots?.map(b=>b.name).join(', ')}</strong><p>{bots?.[0]?.zone} · {bots?.length} observed members</p></div>):<p>No parties currently observed.</p>}</div></section>
<section className="panel"><div className="panel-head"><h2>Guild goals</h2></div><div className="panel-body">{c?.guilds.length?c.guilds.map(g=><div className="memory" key={g.id}><strong>{g.name}</strong><p>{g.progress} confirmed contributions · milestone {g.target}</p><label>Shared goal <select aria-label={`${g.name} goal`} value={g.goal} disabled={busy} onChange={e=>act({type:'guildGoal',guildId:g.id,goal:e.target.value})}><option value="quest">Complete quests together</option><option value="support">Help guildmates in combat</option><option value="explore">Explore other maps</option></select></label></div>):<p>Waiting for guild observations.</p>}</div></section></div>
<section className="panel mt-5"><div className="panel-head"><h2>Social activity</h2><select aria-label="Social activity filter" value={filter} onChange={e=>setFilter(e.target.value)}>{['all','conversation','relationship','invitation','party','leave','recruit','guild'].map(v=><option key={v}>{v}</option>)}</select></div><div className="panel-body">{[...(c?.feed??[])].reverse().filter(e=>filter==='all'||e.kind===filter).map(e=><article className="memory" key={e.id}><small>{new Date(e.at).toLocaleString()} · {e.kind} · {e.status??'observed'}</small><h3>{e.names.join(' ↔ ')}</h3><p style={{whiteSpace:'pre-wrap'}}>{e.text}</p>{e.affinityDelta!==undefined&&<small>Trust change: {e.affinityDelta>0?'+':''}{e.affinityDelta}</small>}</article>)}{!c?.feed.length&&<p>No confirmed social activity recorded yet.</p>}</div></section>
</>;
}
export function OperationsPanel({state,act,busy}:{state:State;act:(a:any)=>Promise<any>;busy:boolean}){
const [data,setData]=useState<any>(null),[error,setError]=useState('');
useEffect(()=>{let alive=true;const refresh=async()=>{try{const r=await fetch('/api/operations');if(!r.ok)throw new Error('Health data unavailable');const d=await r.json();if(alive){setData(d);setError('');}}catch(e){if(alive)setError(String(e));}};void refresh();const timer=setInterval(refresh,10000);return()=>{alive=false;clearInterval(timer);};},[]);
return <><div className="section-grid">{['ubuntu','windows'].map(host=>{const s=data?.samples.find((s:any)=>s.host===host),stale=!s||Date.now()/1000-s.at>180;return <section className="panel" key={host}><div className="panel-head"><h2>{host==='ubuntu'?'Ubuntu server':'Windows PC'}</h2></div><div className="panel-body">{s?<><p>{stale?'Telemetry stale — values may have changed':'Current telemetry'} · {new Date(s.at*1000).toLocaleTimeString()}</p>{[['CPU',s.cpuPercent.toFixed(1)+'%'],['Available RAM',Math.round(s.memoryAvailableMB)+' / '+Math.round(s.memoryTotalMB)+' MB'],['Free disk',s.diskFreeGB.toFixed(1)+' GB'],...(s.databaseMB!==undefined?[['Application database',s.databaseMB.toFixed(1)+' MB'],['Logs',s.logsMB.toFixed(1)+' MB'],['Model',s.modelAvailable?'Available':'Unavailable'],['Runtime recovery',s.runtimeService]]:[])].map(([label,value]:any)=><div className="health-row" key={label}><span>{label}</span><strong>{value}</strong></div>)}{s.memoryAvailableMB<1024&&<p className="notice">Memory pressure: keep the population capped at 50.</p>}<p className="fineprint">{s.maintenance}</p></>:<p>Waiting for telemetry from this machine.</p>}</div></section>;})}</div>
<section className="panel mt-5"><div className="panel-head"><h2>Storage and stalled activity</h2></div><div className="panel-body">{error&&<p role="alert">{error}</p>}<label>Keep application command history for <select aria-label="History retention" disabled={busy} value={state.opsRetentionDays??7} onChange={e=>act({type:'retentionPolicy',days:Number(e.target.value)})}>{[3,7,14,30].map(n=><option value={n} key={n}>{n} days</option>)}</select></label><p className="fineprint">Memories, relationships and memorials are preserved. Acknowledged game transport expires after one day; archived snapshots use the selected retention. Logs rotate at 10 MB with three archives. The social feed holds 250 entries.</p><h3>Possible stalled bots</h3><p className="fineprint">Stationary for over 10 minutes while intending to quest or explore. This is a review signal, not proof of being stuck; no automatic teleporting.</p>{data?.stuck?.length?data.stuck.map((b:any)=><p key={b.name}>{b.name} · {b.zone} · {b.minutes} minutes</p>):<p>No candidates detected.</p>}</div></section></>;
}
+2
View File
File diff suppressed because one or more lines are too long
+22 -18
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
'use client';
import Home from '../page';
export default function Visitor(){return <Home readOnly/>;}
+21 -7
View File
@@ -103,8 +103,8 @@ export async function runLiveAutonomyTick(
options:{forceBotId?:string;now?:number}={} options:{forceBotId?:string;now?:number}={}
){ ){
const now=options.now??Date.now(); const now=options.now??Date.now();
const s=await store.load(); let s=await store.load();
const revision=s.revision; let revision=s.revision;
const events=await recentAleEvents(db,400); const events=await recentAleEvents(db,400);
const candidates=s.bots const candidates=s.bots
@@ -139,29 +139,43 @@ 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 guild=s.community?.guilds.find(g=>g.id===bot.world?.guildId);
if(guild)observation.summary+=` Your guild ${guild.name} works toward ${guild.goal}; confirmed progress ${guild.progress}/${guild.target}. Favor helpful actions toward that goal when safe.`;
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
+118
View File
@@ -0,0 +1,118 @@
import type {Bot,State,StateStore,SocialEntry} from './types';
import {luaString} from './ale';
import {queueAleCommand} from './ale-store';
export function community(s:State){return s.community??(s.community={feed:[],evidence:[],guilds:[],partiesEnabled:true,guildsEnabled:true});}
export function socialEntry(s:State,e:SocialEntry){const c=community(s);if(!c.feed.some(x=>x.id===e.id))c.feed.push(e);c.feed=c.feed.slice(-250);}
export function bondEvidence(s:State,id:string,a:Bot,b:Bot,reason:string,delta:number){
const c=community(s);if(c.evidence.includes(id)||a.id===b.id)return false;
c.evidence.push(id);c.evidence=c.evidence.slice(-1000);
for(const [from,to] of [[a,b],[b,a]]){
let r=from.relationships.find(x=>x.targetId===to.id);
if(!r){r={targetId:to.id,affinity:0,note:reason};from.relationships.push(r);}
r.affinity=Math.max(-100,Math.min(100,r.affinity+delta));r.note=reason;
from.relationships=from.relationships.slice(-100);
}
socialEntry(s,{id,at:new Date().toISOString(),kind:'relationship',names:[a.name,b.name],text:reason,affinityDelta:delta,status:'confirmed'});
return true;
}
export async function changeCommunity(store:StateStore,fn:(s:State)=>void){
for(let n=0;n<4;n++){const s=await store.load(),r=s.revision;fn(s);try{await store.save(s,r);return;}catch(e){if(n===3||!(e instanceof Error)||!e.message.includes('State changed'))throw e;}}
}
export function observePresence(s:State,b:Bot,p:Record<string,unknown>){
const at=Number(p.observedAt);if(!Number.isFinite(at)||at<=(b.world?.at??0)||Math.abs(Date.now()/1000-at)>120)return;
if(typeof p.team!=='number'||typeof p.x!=='number'||typeof p.y!=='number')return;
const old=b.world;
const stationary=old&&old.mapId===p.mapId&&Math.hypot(old.x-p.x,old.y-p.y)<3;
b.world={at,team:p.team,guildId:Number(p.guildId)||0,guildName:String(p.guildName||''),guildRank:Number(p.guildRank)||0,groupId:String(p.groupId||''),groupNames:String(p.groupNames||'').split(',').filter(Boolean),nearby:String(p.nearbyBots||'').split(',').filter(Boolean),inCombat:p.inCombat===true,x:p.x,y:p.y,mapId:Number(p.mapId)||0,stationarySince:stationary?(old.stationarySince??old.at):at};
const c=community(s);
if(b.world.guildId&&!c.guilds.some(g=>g.id===b.world!.guildId))c.guilds.push({id:b.world.guildId,name:b.world.guildName,goal:['quest','support','explore'][b.world.guildId%3] as 'quest'|'support'|'explore',progress:0,target:20});
if(old&&old.mapId!==b.world.mapId&&b.world.guildId){
const g=c.guilds.find(g=>g.id===b.world!.guildId),key=`explore:${b.id}:${b.world.mapId}:${Math.floor(at/86400)}`;
if(g?.goal==='explore'&&!c.evidence.includes(key)){g.progress++;c.evidence.push(key);c.evidence=c.evidence.slice(-1000);socialEntry(s,{id:key,at:new Date().toISOString(),kind:'guild',names:[b.name,g.name],text:'Reached another map for the guild exploration goal.',status:'confirmed'});}
}
}
export function applyCooperation(s:State,p:Record<string,unknown>){
const at=Number(p.observedAt);if(!Number.isFinite(at)||Math.abs(Date.now()/1000-at)>120)return;
const id=String(p.evidenceId||'');if(!id||community(s).evidence.includes(id))return;
const a=s.bots.find(b=>b.autonomy?.characterGuid===p.characterGuid),b=s.bots.find(b=>b.autonomy?.characterGuid===p.peerGuid);
if(!a||!b||a.status==='dead'||b.status==='dead')return;
const kind=p.cooperation;
if(kind!=='quest'&&kind!=='assist')return;
if(bondEvidence(s,id,a,b,kind==='quest'?'Completed the same quest while adventuring together.':'Helped defeat an enemy together.',kind==='quest'?3:1)){
for(const bot of [a,b]){bot.memories.push({id,text:kind==='quest'?`Completed quest ${String(p.questId)} with ${bot.id===a.id?b.name:a.name}.`:`Fought alongside ${bot.id===a.id?b.name:a.name}.`,importance:6,source:'observation',at:new Date().toISOString()});bot.memories=bot.memories.slice(-100);}
if(a.world?.guildId&&a.world.guildId===b.world?.guildId){const g=community(s).guilds.find(g=>g.id===a.world!.guildId);if(g&&((g.goal==='quest'&&kind==='quest')||(g.goal==='support'&&kind==='assist')))g.progress++;}
}
}
const affinity=(a:Bot,b:Bot)=>a.relationships.find(r=>r.targetId===b.id)?.affinity??0;
const fresh=(b:Bot)=>b.status==='online'&&b.autonomy?.managed&&b.world&&Date.now()/1000-b.world.at<90&&!b.world.inCombat;
export function partyDecision(a:Bot,b:Bot){
if(!fresh(a)||!fresh(b)||a.id===b.id||a.world!.team!==b.world!.team||Math.abs(a.level-b.level)>10||!a.world!.nearby.includes(b.name)||!b.world!.nearby.includes(a.name))return {accepted:false,reason:'No compatible nearby pair'};
const willing=(x:Bot,y:Bot)=>affinity(x,y)>=0&&(x.personality.sociability>=35||affinity(x,y)>=5)&&x.personality.activities.some(v=>v==='quest'||v==='explore');
return {accepted:willing(a,b)&&willing(b,a),reason:willing(a,b)&&willing(b,a)?'Both accepted: compatible levels, shared adventure interests, and no hostile bond.':'One character prefers to adventure alone or does not trust the other.'};
}
export function compileCommunityAction(kind:'party'|'leave'|'recruit',a:Bot,b?:Bot){
const lines=[`local a=GetPlayerByName(${luaString(a.name)})`,`if not a or a:GetGUIDLow()~=${a.autonomy!.characterGuid} or not a:IsPlayerbot() or a:IsDead() or a:IsInCombat() then error('Resident unavailable') end`];
if(kind==='leave'){
lines.push(`local g=a:GetGroup();if not g then return 'Already solo' end`,`if tostring(g:GetGUID())~=${luaString(a.world!.groupId)} then error('Party changed') end`,`if #g:GetMembers()~=g:GetMembersCount() then error('Party has offline members') end;for _,m in pairs(g:GetMembers()) do if not m:IsPlayerbot() then error('Human party is protected') end end`,`a:RemoveFromGroup()`,`if a:GetGroup() then error('Departure was not applied') end`);
}else{
if(!b)throw new Error('Peer required');
lines.push(`local b=GetPlayerByName(${luaString(b.name)})`,`if not b or b:GetGUIDLow()~=${b.autonomy!.characterGuid} or not b:IsPlayerbot() or b:IsDead() or b:IsInCombat() or a:GetTeam()~=b:GetTeam() or a:GetMapId()~=b:GetMapId() or a:GetPhaseMask()~=b:GetPhaseMask() or a:GetDistance(b)>35 then error('Peer unavailable nearby') end`);
if(kind==='party')lines.push(`if math.abs(a:GetLevel()-b:GetLevel())>10 then error('Level mismatch') end`,`local g=a:GetGroup();if g and b:GetGroup()==g then return 'Already grouped' end`,`if b:GetGroup() then error('Peer already grouped') end`,`if g then if g:IsRaidGroup() or g:GetMembersCount()>=5 then error('Party full') end;if #g:GetMembers()~=g:GetMembersCount() then error('Party has offline members') end;for _,m in pairs(g:GetMembers()) do if not m:IsPlayerbot() then error('Human party is protected') end end;if not g:AddMember(b) then error('Party join failed') end else g=a:GroupCreate(b) end`,`if not g or b:GetGroup()~=g then error('Party membership was not applied') end`);
else lines.push(`local g=a:GetGuild();if not g or a:GetGuildId()~=${a.world!.guildId} or a:GetGuildRank()~=0 then error('Guild leader unavailable') end`,`if b:GetGuildId()==a:GetGuildId() then return 'Already a member' end`,`if b:GetGuildId()~=0 then error('Already belongs to a guild') end`,`g:AddMember(b)`,`if b:GetGuildId()~=a:GetGuildId() then error('Guild admission failed') end`);
}
lines.push('AzerothAIEmitSnapshots()','return "Confirmed '+kind+'"');return lines.join('; ');
}
async function ensure(db:D1Database){
await db.prepare('CREATE TABLE IF NOT EXISTS community_actions (id TEXT PRIMARY KEY,created_at TEXT,kind TEXT,a TEXT,b TEXT,command_id TEXT,status TEXT,reason TEXT)').run();
await db.prepare('CREATE TABLE IF NOT EXISTS community_clock (id TEXT PRIMARY KEY,at INTEGER)').run();
}
export async function communityTick(store:StateStore,db:D1Database){
await ensure(db);
const pending=await db.prepare("SELECT * FROM community_actions WHERE status='pending' LIMIT 20").all<any>();
for(const action of pending.results){
const receipt=await db.prepare('SELECT status,error FROM ale_commands WHERE id=?').bind(action.command_id).first<any>();
if(!receipt||!['completed','failed'].includes(receipt.status))continue;
await changeCommunity(store,s=>{
const a=s.bots.find(b=>b.id===action.a),b=s.bots.find(b=>b.id===action.b);if(!a)return;
const proposal=community(s).feed.find(e=>e.id===action.id+':proposal');if(proposal){proposal.status=receipt.status==='completed'?'confirmed':'failed';proposal.text=action.reason;}
socialEntry(s,{id:action.id,at:new Date().toISOString(),kind:action.kind,names:[a.name,...(b?[b.name]:[])],text:receipt.status==='completed'?action.reason:String(receipt.error||'Game rejected action').replace(/^.*:\d+:\s*/,''),status:receipt.status==='completed'?'confirmed':'failed'});
if(receipt.status==='completed'&&b&&action.kind==='party'&&bondEvidence(s,action.id+':bond',a,b,'Accepted an invitation to adventure together.',1)){
for(const [from,to] of [[a,b],[b,a]]){from.memories.push({id:action.id,text:'Joined a party with '+to.name+' to adventure together.',importance:6,source:'observation',at:new Date().toISOString()});from.memories=from.memories.slice(-100);}
}
});
await db.prepare('UPDATE community_actions SET status=? WHERE id=?').bind(receipt.status,action.id).run();
}
const now=Date.now();
await db.prepare("INSERT OR IGNORE INTO community_clock VALUES ('tick',0)").run();
const gate=await db.prepare("UPDATE community_clock SET at=? WHERE id='tick' AND at<?").bind(now,now-30_000).run();if(gate.meta.changes!==1)return;
await db.prepare("DELETE FROM community_actions WHERE status!='pending' AND created_at<?").bind(new Date(now-7*86400_000).toISOString()).run();
const s=await store.load(),c=community(s),bots=s.bots.filter(b=>fresh(b));
const recent=await db.prepare('SELECT a,b FROM community_actions WHERE created_at>?').bind(new Date(now-10*60_000).toISOString()).all<any>();
const busy=new Set(recent.results.flatMap(r=>[r.a,r.b]));
for(const a of bots){
if(busy.has(a.id))continue;
// Leave only a bot-only party whose peers have moved away, never a human party.
const far=a.world!.groupId&&a.world!.groupNames.length>1&&a.world!.groupNames.filter(n=>n!==a.name).every(n=>!a.world!.nearby.includes(n));
if(c.partiesEnabled&&far){
await db.prepare('INSERT OR IGNORE INTO community_clock VALUES (?,?)').bind('apart:'+a.id,now).run();
const apart=await db.prepare('SELECT at FROM community_clock WHERE id=?').bind('apart:'+a.id).first<{at:number}>();
if(apart&&now-apart.at>10*60_000){await propose('leave',a,undefined,'Left the party after ten minutes apart.');return;}
}else await db.prepare('DELETE FROM community_clock WHERE id=?').bind('apart:'+a.id).run();
for(const b of bots){
if(busy.has(b.id)||a.id===b.id||!a.world!.nearby.includes(b.name))continue;
if(c.guildsEnabled&&a.world!.guildId&&a.world!.guildRank===0&&!b.world!.guildId&&affinity(a,b)>=3&&affinity(b,a)>=3&&partyDecision(a,b).accepted){await propose('recruit',a,b,`Accepted an invitation to ${a.world!.guildName} after building mutual trust.`);return;}
if(c.partiesEnabled&&!b.world!.groupId&&(!a.world!.groupId||a.world!.groupNames.length<5)){
const decision=partyDecision(a,b);
if(!decision.accepted){if(decision.reason==='No compatible nearby pair')continue;await db.prepare('INSERT INTO community_actions VALUES (?,?,?,?,?,?,?,?)').bind(crypto.randomUUID(),new Date().toISOString(),'declined',a.id,b.id,'','declined',decision.reason).run();await changeCommunity(store,s=>socialEntry(s,{id:crypto.randomUUID(),at:new Date().toISOString(),kind:'invitation',names:[a.name,b.name],text:decision.reason,status:'declined'}));return;}
await propose('party',a,b,decision.reason);return;
}
}
}
async function propose(kind:'party'|'leave'|'recruit',a:Bot,b:Bot|undefined,reason:string){
const command=await queueAleCommand(db,compileCommunityAction(kind,a,b));
await db.prepare('INSERT INTO community_actions VALUES (?,?,?,?,?,?,?,?)').bind(command.id,new Date().toISOString(),kind,a.id,b?.id??'',command.id,'pending',reason).run();
await changeCommunity(store,s=>socialEntry(s,{id:command.id+':proposal',at:new Date().toISOString(),kind:'invitation',names:[a.name,...(b?[b.name]:[])],text:reason+' Awaiting game confirmation.',status:'pending'}));
}
}
+46 -2
View File
@@ -1,6 +1,7 @@
export type ConversationChannel='say'|'whisper'; export type ConversationChannel='say'|'whisper';
export type ConversationRequest={ export type ConversationRequest={
socialTurn?:0|1;
id:string; id:string;
createdAt:string; createdAt:string;
speakerName:string; speakerName:string;
@@ -10,7 +11,7 @@ export type ConversationRequest={
receiver:string; receiver:string;
selectedPlayer:string; selectedPlayer:string;
nearbyBots:string[]; nearbyBots:string[];
status:'pending'|'processing'|'completed'|'failed'; status:'pending'|'processing'|'awaiting_delivery'|'completed'|'failed';
attempts:number; attempts:number;
}; };
@@ -79,7 +80,7 @@ export async function leaseConversation(db:D1Database):Promise<ConversationReque
.bind(staleBefore).run(); .bind(staleBefore).run();
const row=await db.prepare(`SELECT id,created_at,speaker_name,message,channel,zone_name,receiver,selected_player,nearby_bots,status,attempts const row=await db.prepare(`SELECT id,created_at,speaker_name,message,channel,zone_name,receiver,selected_player,nearby_bots,status,attempts
FROM conversation_requests WHERE status='pending' ORDER BY created_at LIMIT 1`).first<any>(); FROM conversation_requests WHERE status='pending' ORDER BY CASE WHEN channel LIKE 'social-%' THEN 1 ELSE 0 END,created_at LIMIT 1`).first<any>();
if(!row)return null; if(!row)return null;
const startedAt=new Date().toISOString(); const startedAt=new Date().toISOString();
@@ -89,6 +90,7 @@ export async function leaseConversation(db:D1Database):Promise<ConversationReque
if(updated.meta.changes!==1)return null; if(updated.meta.changes!==1)return null;
return { return {
socialTurn:row.channel==='social-start'?0:row.channel==='social-reply'?1:undefined,
id:row.id, id:row.id,
createdAt:row.created_at, createdAt:row.created_at,
speakerName:row.speaker_name, speakerName:row.speaker_name,
@@ -124,3 +126,45 @@ export async function cleanupConversations(db:D1Database){
WHERE status IN ('completed','failed') AND completed_at IS NOT NULL AND completed_at<?`) WHERE status IN ('completed','failed') AND completed_at IS NOT NULL AND completed_at<?`)
.bind(before).run(); .bind(before).run();
} }
export async function awaitDelivery(db:D1Database,request:ConversationRequest,botId:string,reply:string,commandId:string){
await db.prepare("UPDATE conversation_requests SET status='awaiting_delivery',result=? WHERE id=?")
.bind(JSON.stringify({request,botId,reply,commandId}),request.id).run();
}
export async function pendingDeliveries(db:D1Database){
await ensure(db);
const rows=await db.prepare("SELECT result FROM conversation_requests WHERE status='awaiting_delivery' ORDER BY created_at LIMIT 50").all<{result:string}>();
const deliveries=[];
for(const row of rows.results){
const delivery=JSON.parse(row.result) as {request:ConversationRequest;botId:string;reply:string;commandId:string};
const command=await db.prepare('SELECT status,error FROM ale_commands WHERE id=?').bind(delivery.commandId).first<{status:string;error:string|null}>();
deliveries.push({...delivery,status:command?.status??'pending',error:command?.error});
}
return deliveries;
}
// One autonomous exchange per two-minute bucket; human chat keeps priority.
export async function queueSocialEncounter(db:D1Database,payload:Record<string,unknown>){
const a=text(payload,'socialA',40),b=text(payload,'socialB',40);
const at=Number(payload.observedAt);
if(!a||!b||a===b||!Number.isFinite(at)||Math.abs(Date.now()/1000-at)>60)return;
await ensure(db);
const now=new Date().toISOString();
const cutoff=new Date(Date.now()-10*60_000).toISOString();
const id='social-'+Math.floor(Date.now()/120_000);
await db.prepare(`INSERT OR IGNORE INTO conversation_requests
(id,created_at,speaker_name,message,channel,zone_name,receiver,selected_player,nearby_bots,status,attempts)
SELECT ?,?,?,?,'social-start',?,?,?,?,'pending',0
WHERE NOT EXISTS (SELECT 1 FROM conversation_requests WHERE status IN ('pending','processing','awaiting_delivery'))
AND NOT EXISTS (SELECT 1 FROM conversation_requests WHERE channel LIKE 'social-%' AND created_at>? AND (speaker_name IN (?,?) OR selected_player IN (?,?)))`)
.bind(id,now,b,'Initiate a brief friendly in-world conversation with your nearby companion.',text(payload,'zoneName',120),a,a,b,cutoff,a,b,a,b).run();
}
export async function queueSocialReply(db:D1Database,request:ConversationRequest,botName:string,reply:string){
if(request.socialTurn!==0)return;
await db.prepare(`INSERT OR IGNORE INTO conversation_requests
(id,created_at,speaker_name,message,channel,zone_name,receiver,selected_player,nearby_bots,status,attempts)
VALUES (?,?,?,?,'social-reply',?,?,?,?,'pending',0)`)
.bind(request.id+':reply',new Date().toISOString(),botName,reply,request.zoneName,request.speakerName,request.speakerName,botName).run();
}
+38 -21
View File
@@ -1,7 +1,8 @@
import {bondEvidence,socialEntry,communityTick} from './community';
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 +21,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 +55,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 +74,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 +106,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 +132,24 @@ 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);
bondEvidence(state,request.id+':bond',bot,peer,'Shared a confirmed conversation.',1);
}
}
socialEntry(state,{id:request.id,at:new Date().toISOString(),kind:'conversation',names:[request.speakerName,bot.name],text:`${request.speakerName}: ${request.message}\n${bot.name}: ${reply}`,status:'confirmed'});
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 +162,24 @@ 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);
await communityTick(store,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 +187,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};
+34 -6
View File
@@ -1,3 +1,4 @@
import {community,observePresence,applyCooperation,socialEntry} from './community';
import { runGM } from './gm'; import { runGM } from './gm';
import { actionSchema } from './validation'; import { actionSchema } from './validation';
import { connectors } from './connectors'; import { connectors } from './connectors';
@@ -72,6 +73,25 @@ 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;
if(kind==='player.snapshot'&&payload.cooperation){applyCooperation(s,payload);await store.save(s,revision);return {matched:true,cooperation:true};}
// 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 +123,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]},
@@ -133,23 +153,24 @@ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,pa
} }
if(b.status==='retired')return {matched:true,botId:b.id,retired:true}; if(b.status==='retired')return {matched:true,botId:b.id,retired:true};
if(kind==='player.snapshot')observePresence(s,b,payload);
b.level=level; b.level=level;
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 +200,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){
@@ -213,6 +234,13 @@ export async function execute(store:StateStore,input:unknown,config:RuntimeConfi
if(a.type==='gm'){ if(a.type==='gm'){
const id=crypto.randomUUID();s.operations[id]={started:Date.now(),botId:'gm'};event(s,'gm',`${a.live?'LIVE':'SIMULATED'} command requested: ${a.command}`);await store.save(s,revision); const id=crypto.randomUUID();s.operations[id]={started:Date.now(),botId:'gm'};event(s,'gm',`${a.live?'LIVE':'SIMULATED'} command requested: ${a.command}`);await store.save(s,revision);
try{const result=await runGM(a.command,a.live,a.confirmed,config);delete s.operations[id];event(s,'gm',`${a.live?'LIVE':'SIMULATED'} ${result.command}: ${result.output}`);await store.save(s,s.revision);return result;}catch(e){delete s.operations[id];event(s,'error',`GM command failed: ${e instanceof Error?e.message:'Unknown error'}`);await store.save(s,s.revision);throw e;} try{const result=await runGM(a.command,a.live,a.confirmed,config);delete s.operations[id];event(s,'gm',`${a.live?'LIVE':'SIMULATED'} ${result.command}: ${result.output}`);await store.save(s,s.revision);return result;}catch(e){delete s.operations[id];event(s,'error',`GM command failed: ${e instanceof Error?e.message:'Unknown error'}`);await store.save(s,s.revision);throw e;}
}else if(a.type==='retentionPolicy'){
s.opsRetentionDays=a.days;
}else if(a.type==='communityPolicy'){
Object.assign(community(s),{partiesEnabled:a.partiesEnabled,guildsEnabled:a.guildsEnabled});
}else if(a.type==='guildGoal'){
const g=community(s).guilds.find(g=>g.id===a.guildId);if(!g)throw new Error('Guild not observed');g.goal=a.goal;g.progress=0;
socialEntry(s,{id:crypto.randomUUID(),at:new Date().toISOString(),kind:'guild',names:[g.name],text:'Guild goal changed to '+a.goal,status:'confirmed'});
}else if(a.type==='director'){ }else if(a.type==='director'){
s.director={experience:a.experience,zone:a.zone,playerName:a.playerName,brief:a.brief}; s.director={experience:a.experience,zone:a.zone,playerName:a.playerName,brief:a.brief};
for(const b of s.bots.filter(b=>b.status!=='retired'&&b.status!=='dead')) b.goal=`${a.experience} in ${a.zone}${a.playerName?' around '+a.playerName:''}: ${a.brief}`.slice(0,200); for(const b of s.bots.filter(b=>b.status!=='retired'&&b.status!=='dead')) b.goal=`${a.experience} in ${a.zone}${a.playerName?' around '+a.playerName:''}: ${a.brief}`.slice(0,200);
+6 -2
View File
@@ -10,7 +10,11 @@ export type DeathRecord = {id:string; at:string; zone:string; level:number; caus
export type Home = {id:string; name:string; zone:string; type:HousingType; capacity:number; ownerIds:string[]; residentIds:string[]; storageSlots:number; safety:number; description:string}; export type Home = {id:string; name:string; zone:string; type:HousingType; capacity:number; ownerIds:string[]; residentIds:string[]; storageSlots:number; safety:number; description:string};
export type Routine = {wakeHour:number; sleepHour:number; preferredZones:string[]}; export type Routine = {wakeHour:number; sleepHour:number; preferredZones:string[]};
export type AutonomyState = {managed:boolean; characterGuid?:number; accountId?:number; observedAt?:string; lastIntentAt?:string; nextIntentAt?:string; lastAleCommandId?:string}; export type AutonomyState = {managed:boolean; characterGuid?:number; accountId?:number; observedAt?:string; lastIntentAt?:string; nextIntentAt?:string; lastAleCommandId?:string};
export type Bot = {id:string; name:string; role:string; race:string; level:number; zone:string; status:Status; tier:AgentTier; mortal:boolean; plotArmorHpFloor:number; simulationBand:SimulationBand; homeId?:string; routine:Routine; autonomy?:AutonomyState; recipeId:string; recipeVersion:number; personality:Recipe; goal:string; memories:Memory[]; relationships:Relationship[]; decisions:number; death?:DeathRecord; lastDecision?:Decision}; export type WorldPresence = {at:number;team:number;guildId:number;guildName:string;guildRank:number;groupId:string;groupNames:string[];nearby:string[];inCombat:boolean;x:number;y:number;mapId:number;stationarySince?:number};
export type SocialEntry = {id:string;at:string;kind:string;names:string[];text:string;affinityDelta?:number;status?:string};
export type GuildGoal = {id:number;name:string;goal:'quest'|'support'|'explore';progress:number;target:number};
export type Community = {feed:SocialEntry[];evidence:string[];guilds:GuildGoal[];partiesEnabled:boolean;guildsEnabled:boolean};
export type Bot = {world?:WorldPresence;id:string; name:string; role:string; race:string; level:number; zone:string; status:Status; tier:AgentTier; mortal:boolean; plotArmorHpFloor:number; simulationBand:SimulationBand; homeId?:string; routine:Routine; autonomy?:AutonomyState; recipeId:string; recipeVersion:number; personality:Recipe; goal:string; memories:Memory[]; relationships:Relationship[]; decisions:number; death?:DeathRecord; lastDecision?:Decision};
export type Decision = {action:Activity; reason:string; goal:string}; export type Decision = {action:Activity; reason:string; goal:string};
export type RealmEvent = {id:string; seq:number; at:string; kind:'system'|'lifecycle'|'decision'|'memory'|'relationship'|'recipe'|'gm'|'death'|'housing'|'population'|'error'; message:string; botId?:string; mode:'simulation'|'azerothcore'}; export type RealmEvent = {id:string; seq:number; at:string; kind:'system'|'lifecycle'|'decision'|'memory'|'relationship'|'recipe'|'gm'|'death'|'housing'|'population'|'error'; message:string; botId?:string; mode:'simulation'|'azerothcore'};
export type HardcoreSettings = {enabled:boolean; requireVerifiedDeath:boolean; memorialize:boolean}; export type HardcoreSettings = {enabled:boolean; requireVerifiedDeath:boolean; memorialize:boolean};
@@ -18,7 +22,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; community?:Community; opsRetentionDays?: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}>;}
+3
View File
@@ -5,6 +5,9 @@ export const recipeSchema=z.object({id:z.string().max(80).optional(),name:z.stri
const populationSchema=z.object({defaultZoneTarget:z.number().int().min(0).max(100),zoneMax:z.number().int().min(1).max(200),serverMax:z.number().int().min(1).max(1000),foregroundMax:z.number().int().min(1).max(200),backgroundMax:z.number().int().min(1).max(1000)}); const populationSchema=z.object({defaultZoneTarget:z.number().int().min(0).max(100),zoneMax:z.number().int().min(1).max(200),serverMax:z.number().int().min(1).max(1000),foregroundMax:z.number().int().min(1).max(200),backgroundMax:z.number().int().min(1).max(1000)});
const brainsSchema=z.object({enabled:z.boolean(),residentModel:z.string().trim().min(1).max(100),heroModel:z.string().trim().min(1).max(100),backgroundUseLLM:z.boolean(),residentMemoryLimit:z.number().int().min(1).max(50),heroMemoryLimit:z.number().int().min(1).max(100),residentMaxTokens:z.number().int().min(64).max(2048),heroMaxTokens:z.number().int().min(64).max(4096)}); const brainsSchema=z.object({enabled:z.boolean(),residentModel:z.string().trim().min(1).max(100),heroModel:z.string().trim().min(1).max(100),backgroundUseLLM:z.boolean(),residentMemoryLimit:z.number().int().min(1).max(50),heroMemoryLimit:z.number().int().min(1).max(100),residentMaxTokens:z.number().int().min(64).max(2048),heroMaxTokens:z.number().int().min(64).max(4096)});
export const actionSchema=z.discriminatedUnion('type',[ export const actionSchema=z.discriminatedUnion('type',[
z.object({type:z.literal('retentionPolicy'),days:z.number().int().min(3).max(30)}),
z.object({type:z.literal('communityPolicy'),partiesEnabled:z.boolean(),guildsEnabled:z.boolean()}),
z.object({type:z.literal('guildGoal'),guildId:z.number().int().positive(),goal:z.enum(['quest','support','explore'])}),
z.object({type:z.literal('createBot'),name:z.string().trim().regex(/^[A-Za-z][A-Za-z '-]{1,23}$/),role:z.enum(['Warrior','Priest','Mage','Rogue','Hunter','Paladin','Warlock','Druid','Shaman']),race:z.enum(['Human','Night Elf','Dwarf','Orc','Undead','Tauren','Gnome','Troll','Blood Elf','Draenei']),recipeId:z.string(),goal:z.string().trim().min(1).max(200)}), z.object({type:z.literal('createBot'),name:z.string().trim().regex(/^[A-Za-z][A-Za-z '-]{1,23}$/),role:z.enum(['Warrior','Priest','Mage','Rogue','Hunter','Paladin','Warlock','Druid','Shaman']),race:z.enum(['Human','Night Elf','Dwarf','Orc','Undead','Tauren','Gnome','Troll','Blood Elf','Draenei']),recipeId:z.string(),goal:z.string().trim().min(1).max(200)}),
z.object({type:z.literal('lifecycle'),botId:z.string(),action:z.enum(['spawn','pause','resume','despawn','retire'])}), z.object({type:z.literal('lifecycle'),botId:z.string(),action:z.enum(['spawn','pause','resume','despawn','retire'])}),
z.object({type:z.literal('think'),botId:z.string()}), z.object({type:z.literal('think'),botId:z.string()}),
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Back up the realm, seed its lifecycle registry, and install the ALE guard.
Run as the Docker-authorized realm owner, with --apply after reviewing the default read-only plan.
The world server must be stopped for --apply. This script never deletes characters.
"""
import argparse
import datetime
import gzip
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import urllib.request
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--lab', type=Path, required=True)
parser.add_argument('--apply', action='store_true')
args = parser.parse_args()
repo = Path(__file__).resolve().parents[2]
lab = args.lab.resolve()
config = {}
for line in (lab / 'runtime.env').read_text().splitlines():
if '=' in line and not line.startswith('#'):
key, value = line.split('=', 1)
config[key] = value.strip().strip('"').strip("'")
env = os.environ.copy()
env['MYSQL_PWD'] = config['DOCKER_DB_ROOT_PASSWORD']
def mysql(sql):
return subprocess.check_output(['docker', 'exec', '-e', 'MYSQL_PWD',
'ac-database', 'mysql', '-uroot', '-N', '-B', '-e', sql], env=env, text=True)
state = json.load(urllib.request.urlopen('http://127.0.0.1:8787/api/state', timeout=10))
assert state['settings']['hardcore']['enabled'], 'Enable hardcore before installing the guard'
raw = mysql("SELECT c.guid,c.name,c.account,c.level,c.totaltime,c.class FROM acore_characters.characters c "
"JOIN acore_auth.account a ON a.id=c.account WHERE a.username REGEXP '^RNDBOT[0-9]+$'")
characters = [line.split('\t') for line in raw.splitlines() if line]
managed = {b.get('autonomy', {}).get('characterGuid'): b for b in state['bots']
if b.get('autonomy', {}).get('managed')}
rows = []
counts = {}
for guid, name, account, level, played, cls in characters:
guid, account = int(guid), int(account)
bot = managed.get(guid)
if bot:
assert bot['name'] == name and bot['autonomy']['accountId'] == account, 'Identity mismatch'
protected = name.lower() == 'zaku' or bool(bot and bot.get('plotArmorHpFloor', 0) > 0)
if protected:
status = 'active'
elif bot and bot['status'] == 'dead':
assert bot.get('death', {}).get('verified'), 'Unverified death cannot retire a character'
status = 'dead'
elif bot and bot['status'] != 'retired':
status = 'active'
elif int(level) == (55 if int(cls) == 6 else 1) and int(played) == 0:
status = 'reserve'
else:
status = 'blocked'
counts[status] = counts.get(status, 0) + 1
rows.append((guid, name, account, int(protected), status))
assert any(name.lower() == 'zaku' and protected for _, name, _, protected, _ in rows)
assert counts.get('reserve', 0) >= counts.get('dead', 0), 'Insufficient unused starting-level reserve'
print('Lifecycle plan:', json.dumps(counts), flush=True)
if not args.apply:
raise SystemExit(0)
running = json.loads(subprocess.check_output(['docker', 'inspect', 'ac-worldserver']))[0]['State']['Running']
assert not running, 'Stop ac-worldserver before applying this migration'
os.umask(0o077)
backup = lab / ('lifecycle-backup-' + datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ'))
backup.mkdir()
(backup / 'realm-state.json').write_text(json.dumps(state))
(backup / 'registry-plan.json').write_text(json.dumps(rows))
dump = subprocess.Popen(['docker', 'exec', '-e', 'MYSQL_PWD', 'ac-database',
'mysqldump', '-uroot', '--single-transaction', '--no-tablespaces', '--set-gtid-purged=OFF',
'--databases', 'acore_auth', 'acore_characters', 'acore_playerbots'], env=env, stdout=subprocess.PIPE)
with gzip.open(backup / 'realm.sql.gz', 'wb') as stream:
shutil.copyfileobj(dump.stdout, stream)
assert dump.wait() == 0, 'Database backup failed; no migration applied'
core = lab / 'azerothcore'
override = core / 'docker-compose.override.yml'
shutil.copy2(override, backup / override.name)
shutil.copytree(core / 'azerothai-lua', backup / 'azerothai-lua')
mysql('''CREATE TABLE IF NOT EXISTS acore_auth.azerothai_lifecycle (
guid INT UNSIGNED PRIMARY KEY, name VARCHAR(64) NOT NULL,
account_id INT UNSIGNED NOT NULL, protected TINYINT NOT NULL DEFAULT 0,
state VARCHAR(16) NOT NULL, died_at TIMESTAMP NULL, activated_at TIMESTAMP NULL,
race TINYINT UNSIGNED NOT NULL DEFAULT 0, entry_level TINYINT UNSIGNED NULL
) ENGINE=InnoDB''')
for column, definition in [('race', 'TINYINT UNSIGNED NOT NULL DEFAULT 0'), ('entry_level', 'TINYINT UNSIGNED NULL')]:
if not mysql(f"SHOW COLUMNS FROM acore_auth.azerothai_lifecycle LIKE '{column}'").strip():
mysql(f'ALTER TABLE acore_auth.azerothai_lifecycle ADD COLUMN {column} {definition}')
def text(value):
return "CONVERT(UNHEX('" + value.encode().hex() + "') USING utf8mb4)"
# Never overwrite existing ledger history on a repeated installation.
values = [f"({guid},{text(name)},{account},{protected},{text(status)},"
+ ("NOW())" if status == 'dead' else "NULL)")
for guid, name, account, protected, status in rows]
mysql("INSERT IGNORE INTO acore_auth.azerothai_lifecycle "
"(guid,name,account_id,protected,state,died_at) VALUES " + ','.join(values))
mysql("UPDATE acore_auth.azerothai_lifecycle l JOIN acore_characters.characters c "
"ON c.guid=l.guid AND c.account=l.account_id AND c.name=l.name SET l.race=c.race")
# The native factory separates manual AddClass accounts from autonomous accounts.
# Make the verified unused reserve selectable without increasing the online cap.
mysql("UPDATE acore_playerbots.playerbots_account_type t "
"JOIN acore_auth.azerothai_lifecycle l ON l.account_id=t.account_id "
"SET t.account_type=1,t.assignment_date=NOW() WHERE l.state='reserve'")
source = (repo / 'ale/azerothai_bridge.lua').read_text()
current = (core / 'azerothai-lua/azerothai_bridge.lua').read_text()
for key in ['POLL_MS', 'PROTECTED', 'KNOWN_BOT_ACCOUNTS']:
pattern = r'^local ' + key + r' = .*$'
line = re.search(pattern, current, re.M)
assert line, key
source, n = re.subn(pattern, lambda match: line.group(0), source, flags=re.M)
assert n == 1, key
(core / 'azerothai-lua/azerothai_bridge.lua').write_text(source)
shutil.copy2(repo / 'ale/azerothai_lifecycle.lua', core / 'azerothai-lua/azerothai_lifecycle.lua')
settings = {'AC_AI_PLAYERBOT_DISABLE_RANDOM_LEVELS': '1',
'AC_AI_PLAYERBOT_RANDOMBOT_STARTING_LEVEL': '1',
'AC_AI_PLAYERBOT_DISABLE_DEATH_KNIGHT_LOGIN': '0'}
content = override.read_text()
for key, value in settings.items():
pattern = r'(?m)^(\s*)' + key + r':.*$'
if re.search(pattern, content):
content = re.sub(pattern, lambda m: m.group(1) + key + ': "' + value + '"', content)
else:
assert ' environment:\n' in content, 'Unexpected compose environment layout'
content = content.replace(' environment:\n', ' environment:\n ' + key + ': "' + value + '"\n', 1)
override.write_text(content)
print('Migration installed. Backup:', backup, flush=True)
print('Recreate ac-worldserver to activate; verify the lifecycle startup line and population counts.', flush=True)
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Install the isolated spectator service; public Funnel setup is a separate step."""
from pathlib import Path
import shutil
import subprocess
repo = Path(__file__).resolve().parents[2]
python = shutil.which('python3')
assert python
units = Path.home() / '.config/systemd/user'
units.mkdir(parents=True, exist_ok=True)
(units / 'azerothai-spectator.service').write_text(f'''[Unit]
Description=AzerothAI read-only spectator monitor
After=network-online.target azerothai-runtime.service
[Service]
WorkingDirectory={repo}
ExecStart={python} {repo}/scripts/spectator-server.py
Restart=always
RestartSec=5
NoNewPrivileges=true
UMask=0077
[Install]
WantedBy=default.target
''')
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
subprocess.run(['systemctl', '--user', 'enable', '--now', 'azerothai-spectator.service'], check=True)
print('Spectator ready on loopback port 8790. Public sharing:')
print('sudo tailscale funnel --bg --https=8443 http://127.0.0.1:8790')
print('Keep the existing private dashboard on port 443.')
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Install current-user runtime recovery and telemetry. Enable lingering for boot startup."""
from pathlib import Path
import shutil,subprocess
repo=Path(__file__).resolve().parents[2]
units=Path.home()/'.config/systemd/user';units.mkdir(parents=True,exist_ok=True)
node=shutil.which('node');python=shutil.which('python3');assert node and python
(units/'azerothai-runtime.service').write_text(f'''[Unit]
Description=AzerothAI web runtime
After=network-online.target
[Service]
WorkingDirectory={repo}
ExecStart={node} {repo}/scripts/start-local-runtime.mjs --lan
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=default.target
''')
(units/'azerothai-operations.service').write_text(f'''[Unit]
Description=AzerothAI telemetry and transport retention
After=azerothai-runtime.service
[Service]
Type=oneshot
WorkingDirectory={repo}
ExecStart=/usr/bin/sg docker -c "{python} {repo}/scripts/operations-agent.py"
TimeoutStartSec=50
''')
(units/'azerothai-operations.timer').write_text('''[Unit]
Description=AzerothAI one-minute health sampling
[Timer]
OnBootSec=90
OnUnitActiveSec=60
[Install]
WantedBy=timers.target
''')
subprocess.run(['systemctl','--user','daemon-reload'],check=True)
# Runtime is enabled but started separately after handing over its existing process.
subprocess.run(['systemctl','--user','enable','azerothai-runtime.service','azerothai-operations.timer'],check=True)
subprocess.run(['systemctl','--user','start','azerothai-operations.timer'],check=True)
print('User services installed. Start the runtime after stopping its previous unmanaged process.')
+32
View File
@@ -0,0 +1,32 @@
# Run as Richard at logon; no administrator password or API tokens are stored here.
$ErrorActionPreference = 'Continue'
$mutex = New-Object System.Threading.Mutex($false, 'Local\AzerothAIRecovery')
if (-not $mutex.WaitOne(0)) { exit }
$ssh = 'C:\Users\richard\Tools\MinGit\usr\bin\ssh.exe'
$key = 'C:\Users\richard\.ssh\id_ed25519_azerothai'
$ollama = 'C:\Users\richard\Tools\Ollama\ollama.exe'
$logRoot = 'C:\Users\richard\Tools\AzerothAI'
New-Item -ItemType Directory -Force $logRoot | Out-Null
while ($true) {
try {
try { Invoke-RestMethod http://127.0.0.1:11434/api/tags -TimeoutSec 3 | Out-Null }
catch {
if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) {
Start-Process $ollama -ArgumentList 'serve' -WindowStyle Hidden
}
}
$tunnel = Get-CimInstance Win32_Process -Filter "Name='ssh.exe'" | Where-Object { $_.CommandLine -like '*127.0.0.1:11434:127.0.0.1:11434*' }
if (-not $tunnel) {
Start-Process $ssh -ArgumentList @('-N','-o','BatchMode=yes','-o','StrictHostKeyChecking=yes','-o','ExitOnForwardFailure=yes','-o','ServerAliveInterval=15','-o','ServerAliveCountMax=3','-i',$key,'-R','127.0.0.1:11434:127.0.0.1:11434','endami@192.168.62.129') -WindowStyle Hidden
}
$os = Get-CimInstance Win32_OperatingSystem
$cpu = (Get-CimInstance Win32_Processor | Measure-Object LoadPercentage -Average).Average
$disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'"
$sample = @{host='windows';at=[DateTimeOffset]::UtcNow.ToUnixTimeSeconds();cpuPercent=[double]$cpu;memoryTotalMB=$os.TotalVisibleMemorySize/1024;memoryAvailableMB=$os.FreePhysicalMemory/1024;diskFreeGB=$disk.FreeSpace/1GB;maintenance='Model and SSH tunnel recovery active at Windows logon'} | ConvertTo-Json -Compress
$encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($sample))
& $ssh -o BatchMode=yes -o ConnectTimeout=8 -o StrictHostKeyChecking=yes -i $key endami@192.168.62.129 "python3 /home/endami/AzerothAI/scripts/operations-agent.py --windows $encoded" 2>&1 | Out-File -Append "$logRoot\recovery.log"
} catch { $_.Exception.Message | Out-File -Append "$logRoot\recovery.log" }
$log = Get-Item "$logRoot\recovery.log" -ErrorAction SilentlyContinue
if ($log -and $log.Length -gt 1MB) { Move-Item -Force $log.FullName "$logRoot\recovery.previous.log" }
Start-Sleep -Seconds 60
}
+24
View File
@@ -0,0 +1,24 @@
# Autonomous conversations
The live bridge proposes nearby, same-faction stationary bot pairs within 15 yards. Delivery allows up to 25 yards. Both
must be alive, out of combat and in the same phase. Opportunities ride the fresh
roster snapshot transport. The database admits at most one exchange per two-minute
clock bucket and enforces ten minutes between social requests involving a bot.
Human requests take queue priority. Ordinary bot chatter does not trigger replies.
An exchange consists of a model-generated opening and one model-generated reply.
Each delivery rechecks distance, faction, phase, life and combat state in the game.
Expired requests and pairs that have moved apart are cancelled. No characters are
teleported or held in place for conversations.
Only a confirmed reply creates reciprocal observation memories and neutral
acquaintance records. This does not invent friendship or change affinity based
solely on speaking. Replay IDs prevent duplicate memories. Each resident retains
at most 100 memories and 100 relationship records on this path. Completed and
failed conversation requests are pruned after 24 hours; character memorials and
other game/event history have separate retention policies.
Memories and relationships are stored in `realm_state.payload` in the application's
D1 SQLite database under `.wrangler/state/v3/d1` in the Ubuntu deployment. This
uses the VM's disk, backed by the host PC's storage. The per-resident memory limit
is not a total disk quota: additional identities and other logs can still grow.
+67
View File
@@ -0,0 +1,67 @@
# Community and operations
## Live social behavior
The Social Life page shows the last 250 confirmed conversations, relationship
changes, invitations, departures, and guild actions. Pending requests and failed
native receipts are distinct from completed activity.
A scheduler checks fresh (under 90 seconds) world observations every 30 seconds.
Party partners must be nearby, same faction, within ten levels, and interested
in questing/exploring. Both characters decide using sociability and their directed
trust scores. Negative trust rejects the invitation. Guild recruitment requires
mutual trust of at least 3, an online bot guild leader, and a guildless candidate.
Characters never abandon an existing guild merely to fill a recruitment target.
Each participant gets ten minutes between proposals. Bot-only parties remain at
five members or fewer; native commands refuse to modify human parties or raids.
A resident may depart after ten minutes apart from all party companions.
Native receipts verify membership. Accepted parties grant +1 reciprocal trust.
Confirmed conversations grant +1, shared completed quests +3, and helping attack
the same enemy +1 (combat help is throttled per pair to five minutes). Trust is
bounded to -100..100. Replayed evidence cannot grant repeated credit within the
bounded evidence window. Memories and relationship lists retain at most 100 rows
per resident; dead/retired identities are not candidates for new activities.
Observed guilds receive an editable shared goal: quest together, support allies,
or explore other maps. The guild goal enters high-level decision context.
Progress uses observed cooperative quests, shared combat, or map changes (once
per member/map/day). Guild creation and forced cross-guild transfers are excluded.
## Health, retention and recovery
System Health displays Ubuntu and Windows samples with explicit stale markers,
CPU, available memory, free disk, application database/log sizes, and model health.
Possible stalled bots are those stationary for ten minutes while intending to
quest/explore, with fresh noncombat observations. No automatic teleport is used.
The operations agent prunes acknowledged game transport at one day, as the relay
already does. Archived snapshots and terminal application commands use the chosen
3–30 day retention (default 7). Pending work, memories, guild goals, relationship
state and memorials are preserved. Current application log files rotate at 10 MB
with three copies. Database files may retain freed SQLite pages for reuse.
The minute operations check also reconciles verified AzerothCore death records
with the native hardcore registry using character GUID, account and exact name.
Protected residents are excluded. Each correction keeps the old registry row
and supporting death in `lifecycle-reconciliation.jsonl`. The Lua guard imports
terminal registry states every ten seconds, removes the retired identity, and
allows its faction's replacement slot to fill under the existing 25/25 cap.
This handles delayed death events received after the registry was first seeded.
Install `deploy/playable/install-user-recovery.py` as the realm owner. It registers
`azerothai-runtime.service` with Restart=always and a minute operations timer in
the user's systemd manager. Enable user lingering for reboot startup. Telemetry
uses the realm owner's existing docker group, including in older user sessions.
Existing system relay/autonomy services remain owned by the system manager.
`deploy/playable/windows-recovery.ps1` checks the local model and SSH tunnel every
minute and sends Windows readings through SSH (no API token stored on Windows).
The scheduled task runs at Windows logon. If local script execution is disabled,
leave the task disabled until the user permits locally authored scripts; do not
bypass a managed execution policy. This does not start VMware or log into Windows.
The VM and PC still need to be running.
Tests: `node scripts/test-core.mjs`, TypeScript checking, and the production build.
Native commands verify identity, availability and permissions again at delivery.
A database snapshot should be taken before the first deployment.
+56
View File
@@ -0,0 +1,56 @@
# Hardcore population replacement
The game-side ALE lifecycle guard closes an identity permanently after death.
It preserves character data and the AzerothAI memorial, removes the character
from the random-bot manager, and refreshes a persistent login veto. Zaku and
protected identities are excluded. GUID, account and name must all match the
installation registry; human accounts are never included.
The installer promotes unused manual-summoning reserve accounts into the native
autonomous pool. The native random-bot manager maintains its configured population target using
unused factory-created characters. Replacements start at level 1 (Death Knights at level 55), with their
own names and AzerothAI recipes/memories assigned when observed. This does not
reset or resurrect a dead character. Duplicate death events are harmless.
The reserve is finite: this deployment began with 450 unused level-1 characters.
This release does not create additional accounts or extend the factory reserve
when it is exhausted. The population will fall below target if no eligible
characters remain. Check `azerothai_lifecycle` state counts before extending it.
Death Knights are eligible and count toward their race’s faction, within the same
25-per-faction cap. Their approved starting level is 55. Normal gameplay can still level a replacement after its initial entry.
The guard allocates new arrivals against actual living faction counts, targeting
25 Alliance and 25 Horde, including protected residents. It holds excess-faction
reserves and excess returning residents offline without retiring them. Returning
residents retain their progress and get priority over unused reserves. New activations record `entry_level=1` or `entry_level=55` for Death Knights; subsequent leveling is allowed.
Native factory-created newcomers are enrolled only from verified RNDBOT accounts
and only when fresh and at their class starting level.
## Installation
1. Build the updated AzerothAI application and start its runtime.
2. Run `python3 deploy/playable/install-lifecycle.py --lab /path/to/lab` to review.
3. Stop `ac-worldserver`.
4. Repeat the command with `--apply`. It backs up auth, character and playerbot
databases, current Lua scripts, compose overrides and the dashboard state.
5. Recreate only the world container with `docker compose up -d --no-deps ac-worldserver`.
6. Restart the ALE relay to load priority delivery for permanent-death events.
The guard is enabled by installing its Lua file, independently of the dashboard
hardcore toggle. Remove the guard and recreate the world before intentionally
switching back to non-hardcore play. Do not erase the registry to recycle deaths.
## Verification and recovery
Verify the lifecycle startup line, absence of lifecycle errors, zero retired
GUIDs online, new reserve activations at level 1, and the native target count.
The dashboard active count excludes unmanaged prototype entries in live mode.
For rollback, stop the world and restore its prior Lua directory and compose
override from the printed backup directory, then recreate the world container.
The previous characters were never deleted. Database restoration is a separate
operator action: it also rolls back gameplay since the backup timestamp.
Run `python3 tests/lifecycle-lua.test.py` with `lupa` installed for isolated
retirement, replay, protection and replacement-entry checks. Run
`node scripts/test-core.mjs` for application tests.
+81
View File
@@ -0,0 +1,81 @@
# Spectator monitor
A separate loopback service serves a public, read-only projection of the realm.
It never forwards viewer paths, methods, cookies, authorization headers or query
parameters to the administrative application. It uses a fixed local GET to
`http://127.0.0.1:8787/api/state`, caches the sanitized result for ten seconds, and
marks delayed observations. Visitors refresh every fifteen seconds.
Published fields: online bot names, class, race, level, faction, zone, guild;
observed bot parties; guild goals and progress; confirmed social activity.
Only autonomous bot-to-bot reply transcripts are public. Human conversations,
whispers, memories, account/character IDs, tokens, configuration and PC telemetry
are excluded. All text is rendered with textContent, never HTML interpolation.
The HTTP service allows explicit public assets and read-only projections described
below. Mutation methods return 405; administrative command paths return 404.
## Install and publish
Run as the realm owner from the repository:
```sh
python3 scripts/test-spectator.py
python3 deploy/playable/install-spectator.py
```
The user service restarts automatically and listens only on `127.0.0.1:8790`.
User lingering, already used by runtime recovery, is required for boot startup.
Keep the existing private Tailscale Serve dashboard on HTTPS 443. Publish only
the spectator service on a separate HTTPS port:
```sh
sudo tailscale funnel --bg --https=8443 http://127.0.0.1:8790
```
If Tailscale presents a Funnel enablement link, the tailnet owner must complete
that authorization. Verify `tailscale funnel status` lists **only port 8443** as
public and port 443 remains tailnet-only. Share the printed HTTPS URL including
`:8443`. Visitors do not need Tailscale. This is a public link, without a password.
The host PC, Ubuntu VM and Tailscale must remain running.
Do not publish port 8787 or 8788, or change the administrative listener into a
Funnel. Hiding controls in the browser would not protect their APIs.
To turn public access off without disturbing the private dashboard:
```sh
sudo tailscale funnel --https=8443 off
```
To stop the local spectator service:
```sh
systemctl --user disable --now azerothai-spectator
```
After changing spectator source or assets, restart its service:
```sh
systemctl --user restart azerothai-spectator
```
## Shared dashboard (visitor edition)
The public root now serves the application's `/visitor` route, which uses the
same React dashboard and CSS as the administrator. Navigation, filters, profiles,
recipe details and the world monitor remain usable. Editing forms and GM inputs
are disabled; the action dispatcher also refuses writes in visitor mode.
The public server independently rejects every mutation method, including server
actions, and never proxies a viewer-selected backend route or request headers.
`/api/state` is an explicit public projection shaped for the shared UI. Bot memory
views contain confirmed shared social history, not private player conversations
or operator-injected memories. The event stream is a polling view of that public
history. `/api/health`, `/api/world-monitor` and `/api/operations` return selected
read-only indicators. Native account identifiers, raw command queues, credentials,
endpoint URLs and private maintenance logs are omitted. Built JavaScript/CSS/font
assets are served from `dist/client` with traversal and source-map requests denied.
Build with `pnpm build` before restarting `azerothai-runtime` and
`azerothai-spectator`. The existing Funnel address and private admin address stay
separate. No cache actions are added.
+2 -2
View File
@@ -9,11 +9,11 @@
"install:ci": "bash scripts/install-pnpm.sh", "install:ci": "bash scripts/install-pnpm.sh",
"dev": "node scripts/run-framework.mjs dev", "dev": "node scripts/run-framework.mjs dev",
"build": "node scripts/run-framework.mjs build", "build": "node scripts/run-framework.mjs build",
"start": "node --import ./scripts/sites-env.mjs ./node_modules/wrangler/bin/wrangler.js dev --config dist/server/wrangler.json --local --persist-to .wrangler/state --ip 127.0.0.1 --inspector-port 0 --env-file .dev.vars", "start": "node scripts/start-local-runtime.mjs",
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next", "lint": "eslint . --ignore-pattern dist --ignore-pattern .next",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"bench:brains": "node scripts/benchmark-brains.mjs", "bench:brains": "node scripts/benchmark-brains.mjs",
"start:lan": "node --import ./scripts/sites-env.mjs ./node_modules/wrangler/bin/wrangler.js dev --config dist/server/wrangler.json --local --persist-to .wrangler/state --ip 0.0.0.0 --port 8787 --inspector-port 0 --env-file .dev.vars" "start:lan": "node scripts/start-local-runtime.mjs --lan"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.7.0", "@base-ui/react": "^1.7.0",
+21 -5
View File
@@ -93,12 +93,20 @@ function rows(sql){
} }
async function deliverEvents(){ async function deliverEvents(){
// Reserve capacity for current snapshots so historical events cannot starve
// live decisions. The remaining slots continue draining history in order.
const fresh=rows(`SELECT id,HEX(kind),HEX(payload)
FROM acore_auth.azerothai_event_outbox
WHERE delivered_at IS NULL AND kind='player.snapshot'
AND created_at >= NOW() - INTERVAL 90 SECOND
ORDER BY id ASC LIMIT ${Math.max(1,Math.floor(EVENT_BATCH/2))}`);
const batch=rows(`SELECT id,HEX(kind),HEX(payload) const batch=rows(`SELECT id,HEX(kind),HEX(payload)
FROM acore_auth.azerothai_event_outbox FROM acore_auth.azerothai_event_outbox
WHERE delivered_at IS NULL WHERE delivered_at IS NULL
ORDER BY CASE WHEN kind IN ('player.whisper','player.chat') THEN 0 ELSE 1 END, id ${fresh.length?`AND id NOT IN (${fresh.map(row=>Number(row[0])).join(',')})`:''}
LIMIT ${EVENT_BATCH}`); ORDER BY CASE WHEN kind IN ('player.permanent-death','bridge.ready') THEN 0 WHEN kind IN ('player.whisper','player.chat') AND created_at >= NOW() - INTERVAL 90 SECOND THEN 1 ELSE 2 END, id
for(const [id,kindHex,payloadHex] of batch){ LIMIT ${Math.max(1,EVENT_BATCH-fresh.length)}`);
for(const [id,kindHex,payloadHex] of [...fresh,...batch]){
const kind=Buffer.from(kindHex||'','hex').toString('utf8'); const kind=Buffer.from(kindHex||'','hex').toString('utf8');
const payloadText=Buffer.from(payloadHex||'','hex').toString('utf8'); const payloadText=Buffer.from(payloadHex||'','hex').toString('utf8');
let payload; let payload;
@@ -116,7 +124,7 @@ async function deliverEvents(){
mysql(`UPDATE acore_auth.azerothai_event_outbox SET delivered_at=NOW(6) WHERE id=${Number(id)}`); mysql(`UPDATE acore_auth.azerothai_event_outbox SET delivered_at=NOW(6) WHERE id=${Number(id)}`);
if(EVENT_GAP_MS)await sleep(EVENT_GAP_MS); if(EVENT_GAP_MS)await sleep(EVENT_GAP_MS);
} }
return batch.length; return fresh.length+batch.length;
} }
async function deliverResults(){ async function deliverResults(){
@@ -136,6 +144,14 @@ async function deliverResults(){
function cleanup(){ function cleanup(){
mysql(` mysql(`
CREATE TABLE IF NOT EXISTS acore_auth.azerothai_snapshot_archive LIKE acore_auth.azerothai_event_outbox;
INSERT IGNORE INTO acore_auth.azerothai_snapshot_archive
SELECT * FROM acore_auth.azerothai_event_outbox
WHERE delivered_at IS NULL AND kind='player.snapshot'
AND created_at < NOW() - INTERVAL 90 SECOND;
UPDATE acore_auth.azerothai_event_outbox e
JOIN acore_auth.azerothai_snapshot_archive a ON a.id=e.id
SET e.delivered_at=NOW(6) WHERE e.delivered_at IS NULL;
UPDATE acore_auth.azerothai_event_outbox UPDATE acore_auth.azerothai_event_outbox
SET delivered_at=NOW(6) SET delivered_at=NOW(6)
WHERE delivered_at IS NULL WHERE delivered_at IS NULL
@@ -151,10 +167,10 @@ console.log(`AzerothAI ALE DB relay started: ${BASE}, poll ${POLL_MS}ms`);
while(!stopping){ while(!stopping){
try{ try{
if(Date.now()-lastCleanup>60_000){cleanup();lastCleanup=Date.now();}
await deliverEvents(); await deliverEvents();
await deliverResults(); await deliverResults();
await pullCommand(); await pullCommand();
if(Date.now()-lastCleanup>60_000){cleanup();lastCleanup=Date.now();}
await sleep(POLL_MS); await sleep(POLL_MS);
}catch(error){ }catch(error){
console.error(new Date().toISOString(),error instanceof Error?error.message:String(error)); console.error(new Date().toISOString(),error instanceof Error?error.message:String(error));
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Local telemetry and bounded terminal-log retention. No character data is deleted."""
import base64,json,os,shutil,subprocess,sys,time,urllib.request
from pathlib import Path
repo=Path(__file__).resolve().parents[1]
lab=Path(os.environ.get('AZEROTHAI_PLAYABLE_ROOT',str(Path.home()/'azerothai-pc-lab')))
def config(path):
out={}
for line in path.read_text().splitlines():
if '=' in line and not line.startswith('#'):
k,v=line.split('=',1);out[k]=v.strip().strip('"').strip("'")
return out
cfg={**config(lab/'runtime.env'),**config(repo/'.dev.vars')}
base='http://127.0.0.1:8787'
def send(data):
request=urllib.request.Request(base+'/api/operations',data=json.dumps(data).encode(),headers={'Content-Type':'application/json','Authorization':'Bearer '+cfg['AUTONOMY_TOKEN']})
with urllib.request.urlopen(request,timeout=20) as r:assert r.status==200
if len(sys.argv)==3 and sys.argv[1]=='--windows':
data=json.loads(base64.b64decode(sys.argv[2]));data['host']='windows';send(data);sys.exit(0)
def bytes_in(path):
return sum(p.stat().st_size for p in path.rglob('*') if p.is_file()) if path.exists() else 0
def cpu():
v=list(map(int,Path('/proc/stat').read_text().splitlines()[0].split()[1:]));return sum(v[:8]),v[3]+v[4]
a=cpu();time.sleep(1);b=cpu();used=100*(1-(b[1]-a[1])/max(1,b[0]-a[0]))
mem={line.split(':')[0]:int(line.split()[1])/1024 for line in Path('/proc/meminfo').read_text().splitlines()}
model=False
try:
u=cfg.get('LLM_URL','').rstrip('/')+'/api/tags'
with urllib.request.urlopen(u,timeout=5) as r:model=r.status==200
except Exception:pass
maintenance=[]
# Copy/truncate preserves existing service file descriptors. Three bounded archives.
paths=[lab/'autonomy.log',lab/'ale-relay.log',Path.home()/'azerothai-direct-runtime-live.log',lab/'operations.log']
for log in paths:
if log.exists() and log.stat().st_size>10*1024*1024:
for n in [3,2]:
older=Path(str(log)+'.'+str(n-1))
if older.exists():older.replace(str(log)+'.'+str(n))
shutil.copy2(log,str(log)+'.1')
with log.open('r+') as f:f.truncate(0)
maintenance.append('Rotated '+log.name)
# Only acknowledged transport data; never purge pending messages or lifecycle history.
try:
state=json.load(urllib.request.urlopen(base+'/api/state',timeout=10));days=max(3,min(30,int(state.get('opsRetentionDays',7))))
env=os.environ.copy();env['MYSQL_PWD']=cfg['DOCKER_DB_ROOT_PASSWORD']
def sql(q):return subprocess.check_output(['docker','exec','-e','MYSQL_PWD','ac-database','mysql','-uroot','-N','-B','-e',q],env=env,text=True)
# Reconcile delayed verified deaths by the full native identity. This never
# revives an identity, changes a protected resident, or counts a human as a bot.
if state.get('settings',{}).get('hardcore',{}).get('enabled'):
retired=0
registry={int(line.split('\t')[0]):line for line in sql("SELECT guid,name,account_id,state,died_at FROM acore_auth.azerothai_lifecycle WHERE state<>'dead' AND protected=0 AND LOWER(name)<>'zaku'").splitlines() if line}
for bot in state.get('bots',[]):
identity=bot.get('autonomy') or {};death=bot.get('death') or {}
if bot.get('status')!='dead' or not death.get('verified') or death.get('source')!='azerothcore' or not identity.get('managed'):continue
if not bot.get('mortal') or bot.get('plotArmorHpFloor',0)>0 or bot.get('name','').lower()=='zaku':continue
guid=identity.get('characterGuid');account=identity.get('accountId')
if type(guid) is not int or type(account) is not int or guid<=0 or account<=0:continue
before=registry.get(guid,'');fields=before.split('\t')
if len(fields)!=5 or fields[1]!=bot['name'] or int(fields[2])!=account:continue
name=bot['name'].encode().hex()
where=f"guid={guid} AND account_id={account} AND BINARY name=UNHEX('{name}') AND protected=0 AND LOWER(name)<>'zaku' AND state<>'dead'"
# Keep an audit of the old registry row and supporting death before changing it.
audit=lab/'lifecycle-reconciliation.jsonl'
with audit.open('a') as f:f.write(json.dumps({'at':time.time(),'before':before.strip(),'death':death})+'\n')
changed=sql("UPDATE acore_auth.azerothai_lifecycle SET state='dead',died_at=COALESCE(died_at,NOW()) WHERE "+where+'; SELECT ROW_COUNT()')
retired+=int(changed.strip())
maintenance.append(f'Hardcore registry checked; {retired} delayed deaths reconciled')
for table,column in [('azerothai_event_outbox','delivered_at'),('azerothai_result_outbox','delivered_at')]:
sql(f'DELETE FROM acore_auth.{table} WHERE {column} IS NOT NULL AND {column}<NOW()-INTERVAL 1 DAY LIMIT 2000')
sql(f'DELETE FROM acore_auth.azerothai_snapshot_archive WHERE created_at<NOW()-INTERVAL {days} DAY LIMIT 2000')
maintenance.append('Acknowledged transport and snapshot retention checked')
except Exception as e:maintenance.append('Retention check needs attention: '+type(e).__name__)
service=subprocess.run(['systemctl','--user','is-active','azerothai-runtime'],capture_output=True,text=True).stdout.strip()
send({'host':'ubuntu','at':time.time(),'cpuPercent':max(0,min(100,used)),'memoryTotalMB':mem['MemTotal'],'memoryAvailableMB':mem['MemAvailable'],'diskFreeGB':shutil.disk_usage(repo).free/1024**3,'databaseMB':bytes_in(repo/'.wrangler/state/v3/d1')/1024**2,'logsMB':sum(p.stat().st_size for p in paths if p.exists())/1024**2,'modelAvailable':model,'runtimeService':service,'maintenance':'; '.join(maintenance)[:500]})
print(time.strftime('%Y-%m-%dT%H:%M:%SZ',time.gmtime()),'telemetry and retention complete')
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""Read-only public realm projection. Never proxy arbitrary routes or headers."""
import json
import math
import os
import threading
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] / 'spectator'
ALLIANCE = {'Human', 'Dwarf', 'Night Elf', 'Gnome', 'Draenei'}
HORDE = {'Orc', 'Undead', 'Tauren', 'Troll', 'Blood Elf'}
def text(value, limit=200):
return str(value or '')[:limit]
def number(value, default=0):
return value if type(value) in (int, float) and math.isfinite(value) else default
def project(state):
"""Explicit field allowlist: no settings, IDs, memories, whispers or host data."""
managed = [b for b in state.get('bots', []) if b.get('autonomy', {}).get('managed')]
names = {b['name'] for b in managed}
bots = []
parties = {}
for b in managed:
if b.get('status') != 'online':
continue
world = b.get('world') or {}
race = text(b.get('race'), 40)
team = world.get('team')
faction = ('Alliance' if team == 0 else 'Horde' if team == 1 else
'Alliance' if race in ALLIANCE else 'Horde' if race in HORDE else 'Unknown')
bots.append({'name': text(b.get('name'), 40), 'class': text(b.get('role'), 40),
'race': race, 'level': number(b.get('level')), 'zone': text(b.get('zone'), 100),
'faction': faction, 'guild': text(world.get('guildName'), 80)})
members = sorted({text(n, 40) for n in world.get('groupNames', []) if n in names})
if world.get('groupId') and len(members) > 1:
parties[tuple(members)] = {'members': members, 'faction': faction}
community = state.get('community') or {}
feed = []
for e in community.get('feed', []):
if e.get('status') != 'confirmed' or e.get('kind') not in {'conversation', 'relationship', 'party', 'leave', 'recruit', 'guild'}:
continue
# Only completed autonomous bot-to-bot exchanges. Human conversations,
# whispers and synthetic opener prompts never enter this public feed.
if e['kind'] == 'conversation' and not (
str(e.get('id', '')).startswith('social-') and str(e.get('id', '')).endswith(':reply')
and len(e.get('names', [])) == 2 and all(n in names for n in e['names'])):
continue
feed.append({'at': text(e.get('at'), 40), 'kind': e['kind'],
'names': [text(n, 80) for n in e.get('names', [])[:5]],
'text': text(e.get('text'), 1200)})
guilds = [{'name': text(g.get('name'), 80), 'goal': text(g.get('goal'), 30),
'progress': number(g.get('progress')), 'target': number(g.get('target'), 20)}
for g in community.get('guilds', [])[:100]]
return {'rosterAt': number(state.get('aleRosterAt')), 'fetchedAt': int(time.time()),
'bots': sorted(bots, key=lambda b: (b['faction'], b['name'])),
'parties': list(parties.values()), 'guilds': guilds, 'feed': feed[-60:][::-1]}
def public_state(state):
"""Admin-shaped data for the shared read-only UI, with an explicit allowlist."""
def pick(obj, keys):
return {k: obj[k] for k in keys.split() if k in obj and isinstance(obj[k], (str, int, float, bool))}
def recipe(r):
return {**pick(r, 'id name description sociability aggression curiosity economy version'),
'tags': [text(t, 80) for t in r.get('tags', [])],
'activities': [a for a in r.get('activities', []) if a in ['quest','explore','trade','rest','socialize']]}
projected = project(state)
feed = [{**e, 'id': 'public-'+str(i), 'status': 'confirmed'} for i, e in enumerate(reversed(projected['feed']))]
bots = []
for b in state.get('bots', []):
if not b.get('autonomy', {}).get('managed'):
continue
bot = pick(b, 'id name role race level zone status tier mortal plotArmorHpFloor simulationBand homeId recipeId recipeVersion goal decisions')
bot['personality'] = recipe(b.get('personality', {}))
bot['routine'] = {**pick(b.get('routine', {}), 'wakeHour sleepHour'), 'preferredZones': b.get('routine', {}).get('preferredZones', [])}
bot['autonomy'] = {'managed': True, **pick(b.get('autonomy', {}), 'observedAt lastIntentAt nextIntentAt')}
world = b.get('world') or {}
bot['world'] = {**pick(world, 'at team guildId guildName guildRank groupId inCombat x y mapId stationarySince'),
'groupNames': [n for n in world.get('groupNames', []) if any(x['name']==n for x in state['bots'] if x.get('autonomy',{}).get('managed'))], 'nearby': []}
bot['relationships'] = [pick(r, 'targetId affinity note') for r in b.get('relationships', [])]
# Show shared history, never personal whispers or operator-injected memories.
bot['memories'] = [{'id': e['id'], 'text': e['text'], 'at': e['at'], 'source': 'observation', 'importance': 5}
for e in feed if b['name'] in e['names']]
if b.get('death'):
bot['death'] = pick(b['death'], 'id at zone level cause killer source verified')
if b.get('lastDecision'):
bot['lastDecision'] = {'action': b['lastDecision']['action'], 'reason': 'Following the resident’s current activity.', 'goal': bot.get('goal','')}
bots.append(bot)
settings = state.get('settings') or {}
return {'revision': state.get('revision',0), 'sequence': state.get('sequence',0), 'aleRosterAt': state.get('aleRosterAt',0),
'bots': bots, 'recipes': [recipe(r) for r in state.get('recipes',[])],
'homes': [{**pick(h,'id name zone type capacity storageSlots safety description'), 'ownerIds':h.get('ownerIds',[]), 'residentIds':h.get('residentIds',[])} for h in state.get('homes',[])],
'events': [{'id':e['id'],'seq':i,'at':e['at'],'kind':'relationship' if e['kind']=='relationship' else 'lifecycle','message':e['text'],'mode':'azerothcore'} for i,e in enumerate(feed)],
'settings': {**pick(settings,'connector provider model'),
'brains':pick(settings.get('brains',{}),'enabled residentModel heroModel backgroundUseLLM residentMemoryLimit heroMemoryLimit residentMaxTokens heroMaxTokens'),
'hardcore':pick(settings.get('hardcore',{}),'enabled requireVerifiedDeath memorialize'),
'population':pick(settings.get('population',{}),'defaultZoneTarget zoneMax serverMax foregroundMax backgroundMax')},
'director':{**pick(state.get('director',{}),'experience zone brief'),'playerName':''}, 'population':{}, 'operations':{},
'opsRetentionDays':state.get('opsRetentionDays',7),
'community':{'feed':feed,'evidence':[],'guilds':[{**g,'id':i} for i,g in enumerate(projected['guilds'])],
'partiesEnabled':state.get('community',{}).get('partiesEnabled',True),
'guildsEnabled':state.get('community',{}).get('guildsEnabled',True)}}
def local_get(path):
opener = urllib.request.build_opener(NoRedirect(), urllib.request.ProxyHandler({}))
with opener.open('http://127.0.0.1:8787'+path, timeout=10) as response:
return response.read(8 * 1024 * 1024)
class Snapshot:
def __init__(self):
self.lock = threading.Lock()
self.data = None
self.checked = 0
self.failed = False
def get(self):
with self.lock:
if time.monotonic() - self.checked > 10:
try:
# Fixed local endpoint. Viewer input cannot affect this request.
opener = urllib.request.build_opener(NoRedirect(), urllib.request.ProxyHandler({}))
with opener.open('http://127.0.0.1:8787/api/state', timeout=5) as response:
raw = response.read(8 * 1024 * 1024 + 1)
if len(raw) > 8 * 1024 * 1024:
raise ValueError('State too large')
state = json.loads(raw)
self.data = {**project(state), "state": public_state(state)}
self.failed = False
except Exception:
self.failed = True
self.checked = time.monotonic()
if self.data is None:
return None
return {**self.data, 'stale': self.failed or time.time() - self.data['rosterAt'] > 120}
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
SNAPSHOT = Snapshot()
ASSETS = {'/': ('index.html', 'text/html; charset=utf-8'),
'/app.js': ('app.js', 'text/javascript; charset=utf-8'),
'/style.css': ('style.css', 'text/css; charset=utf-8')}
class Handler(BaseHTTPRequestHandler):
server_version = 'AzerothAI-Spectator'
sys_version = ''
def setup(self):
super().setup()
self.connection.settimeout(10)
def reply(self, status, body, content_type='application/json; charset=utf-8'):
self.send_response(status)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', 'no-store')
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('X-Frame-Options', 'DENY')
self.send_header('Referrer-Policy', 'no-referrer')
self.send_header('X-Robots-Tag', 'noindex, nofollow')
self.send_header('Content-Security-Policy', "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
self.end_headers()
if self.command != 'HEAD':
self.wfile.write(body)
def do_GET(self):
path = self.path.split('?', 1)[0]
if path in ('/', '/visitor', '/visitor/'):
try:
self.reply(200, local_get('/visitor'), 'text/html; charset=utf-8')
except Exception:
self.reply(503, b'{"error":"Realm temporarily unavailable"}')
elif path == '/api/state':
data = SNAPSHOT.get()
self.reply(200 if data else 503, json.dumps(data['state'] if data else {'error':'Realm unavailable'}).encode())
elif path == '/api/health':
data = SNAPSHOT.get()
self.reply(200, json.dumps({'ok':bool(data),'aleConfigured':True,'aleActiveRecently':bool(data and not data['stale']), 'llmConfigured':bool(data and data['state']['settings'].get('provider')!='deterministic')}).encode())
elif path == '/api/world-monitor':
data = SNAPSHOT.get()
at = data['rosterAt'] if data else 0
from datetime import datetime, timezone
self.reply(200, json.dumps({'ok':bool(data),'ale':{'lastEventAt':datetime.fromtimestamp(at,timezone.utc).isoformat() if at else None,'activeRecently':bool(data and not data['stale'])},'events':[],'commands':{}}).encode())
elif path == '/api/operations':
try:
health = json.loads(local_get('/api/operations'))
samples = [{k:v for k,v in row.items() if k in ('host','at','cpuPercent','memoryTotalMB','memoryAvailableMB','diskFreeGB','databaseMB','logsMB','modelAvailable','runtimeService')} for row in health.get('samples',[])]
self.reply(200, json.dumps({'samples':samples,'stuck':[{k:v for k,v in row.items() if k in ('name','zone','minutes')} for row in health.get('stuck',[])]}).encode())
except Exception:
self.reply(503,b'{"error":"Health data unavailable"}')
elif path.startswith(('/assets/', '/_vinext/', '/_next/')):
# Built public assets only. No source maps, dotfiles or traversal.
import mimetypes
root = ROOT.parent / 'dist/client'
asset = (root / path.lstrip('/')).resolve()
if '%' in path or '..' in path or not asset.is_relative_to(root.resolve()) or asset.suffix not in ('.js','.css','.woff','.woff2','.png','.svg','.ico') or not asset.is_file():
self.reply(404,b'{"error":"Not found"}')
else:
self.reply(200,asset.read_bytes(),mimetypes.guess_type(str(asset))[0] or 'application/octet-stream')
elif path in ASSETS:
filename, content_type = ASSETS[path]
self.reply(200, (ROOT / filename).read_bytes(), content_type)
elif path == '/api/realm':
data = SNAPSHOT.get()
self.reply(200 if data else 503, json.dumps(data or {'error': 'Realm temporarily unavailable'}).encode())
elif path == '/health':
self.reply(200, b'{"service":"spectator","readOnly":true}')
elif path == '/robots.txt':
self.reply(200, b'User-agent: *\nDisallow: /\n', 'text/plain; charset=utf-8')
else:
self.reply(404, b'{"error":"Not found"}')
do_HEAD = do_GET
def reject(self):
self.close_connection = True
self.reply(405, b'{"error":"This monitor is read-only"}')
do_POST = do_PUT = do_PATCH = do_DELETE = do_OPTIONS = do_TRACE = do_CONNECT = reject
def log_message(self, format, *args):
pass
class Server(ThreadingHTTPServer):
daemon_threads = True
slots = threading.BoundedSemaphore(24)
def process_request(self, request, client_address):
if not self.slots.acquire(blocking=False):
self.shutdown_request(request)
return
try:
super().process_request(request, client_address)
except Exception:
self.slots.release()
raise
def process_request_thread(self, request, client_address):
try:
super().process_request_thread(request, client_address)
finally:
self.slots.release()
if __name__ == '__main__':
Server(('127.0.0.1', int(os.environ.get('AZEROTHAI_SPECTATOR_PORT', '8790'))), Handler).serve_forever()
+28
View File
@@ -0,0 +1,28 @@
// Run the built worker without Wrangler's development proxy or file watcher.
import './sites-env.mjs';
import {createRequire} from 'node:module';
import path from 'node:path';
import {readdirSync} from 'node:fs';
import {unstable_getMiniflareWorkerOptions} from 'wrangler';
const require = createRequire(import.meta.url);
const wranglerRequire = createRequire(require.resolve('wrangler'));
const {Miniflare} = wranglerRequire('miniflare');
const config = path.resolve('dist/server/wrangler.json');
const {workerOptions, externalWorkers, main} = unstable_getMiniflareWorkerOptions(config);
if (!main) throw new Error('Build the application before starting the local runtime');
const root = path.dirname(main);
const modules = readdirSync(root, {recursive: true})
.filter(file => /\.(m?js)$/.test(file))
.map(file => ({type: 'ESModule', path: path.join(root, file)}));
modules.sort((a, b) => a.path === main ? -1 : b.path === main ? 1 : 0);
const runtime = new Miniflare({
host: process.env.RUNTIME_HOST || (process.argv.includes('--lan') ? '0.0.0.0' : '127.0.0.1'),
port: Number(process.env.RUNTIME_PORT || 8787),
d1Persist: path.resolve('.wrangler/state/v3/d1'),
workers: [{...workerOptions, modules, modulesRoot: root}, ...externalWorkers],
});
console.log(`AzerothAI local runtime ready: ${await runtime.ready}`);
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, async () => { await runtime.dispose(); process.exit(0); });
}
+2 -2
View File
@@ -1,6 +1,6 @@
import {spawnSync} from 'node:child_process'; import {spawnSync} from 'node:child_process';
import {writeFileSync,mkdirSync} from 'node:fs'; import {writeFileSync,mkdirSync} from 'node:fs';
const result=spawnSync(process.execPath,['node_modules/typescript/bin/tsc','core/service.ts','core/seed.ts','core/ale.ts','--outDir','.test-build','--module','commonjs','--target','es2022','--moduleResolution','node','--esModuleInterop','--skipLibCheck','--strict'],{stdio:'inherit'}); const result=spawnSync(process.execPath,['node_modules/typescript/bin/tsc','core/service.ts','core/seed.ts','core/ale.ts','core/conversation.ts','core/autonomy.ts','--types','@cloudflare/workers-types,node','--outDir','.test-build','--module','commonjs','--target','es2022','--moduleResolution','node','--esModuleInterop','--skipLibCheck','--strict'],{stdio:'inherit'});
if(result.status)process.exit(result.status); if(result.status)process.exit(result.status);
mkdirSync('.test-build',{recursive:true});writeFileSync('.test-build/package.json','{"type":"commonjs"}'); mkdirSync('.test-build',{recursive:true});writeFileSync('.test-build/package.json','{"type":"commonjs"}');
process.exit(spawnSync(process.execPath,['--test','tests/core.test.cjs','tests/hardcore.test.cjs','tests/living-world.test.cjs','tests/ale.test.cjs','tests/brains.test.cjs'],{stdio:'inherit'}).status??1); process.exit(spawnSync(process.execPath,['--test','tests/core.test.cjs','tests/hardcore.test.cjs','tests/living-world.test.cjs','tests/ale.test.cjs','tests/brains.test.cjs','tests/conversation.test.cjs','tests/autonomy.test.cjs','tests/community.test.cjs'],{stdio:'inherit'}).status??1);
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
import importlib.util
import json
import threading
import unittest
from unittest.mock import patch
import urllib.error
import urllib.request
from pathlib import Path
spec = importlib.util.spec_from_file_location('spectator', Path(__file__).with_name('spectator-server.py'))
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
class SpectatorTests(unittest.TestCase):
def test_public_projection_excludes_secrets_and_whispers(self):
bots = [{'name': n, 'status': 'online', 'race': r, 'role': 'Death Knight', 'level': 55,
'autonomy': {'managed': True, 'accountId': 123}, 'memories': ['PRIVATE_MEMORY'],
'world': {'team': team, 'groupId': 'internal-guid', 'groupNames': ['A', 'B', 'HumanPlayer']}}
for n, r, team in [('A', 'Human', 0), ('B', 'Orc', 1)]]
feed = [{'id': i, 'kind': 'conversation', 'status': 'confirmed', 'names': names, 'text': t}
for i, names, t in [('whisper-1', ['HumanPlayer', 'A'], 'PRIVATE_WHISPER'),
('social-1', ['A', 'B'], 'SYNTHETIC_PROMPT'),
('social-1:reply', ['A', 'B'], 'Hello friend'),
('social-2:reply', ['HumanPlayer', 'A'], 'NOT_BOT_CHAT')]]
source={'bots': bots, 'settings': {'token': 'SECRET'}, 'community': {'feed': feed}}
projected = m.project(source)
public = m.public_state(source)
for forbidden in ['PRIVATE_MEMORY','PRIVATE_WHISPER','SYNTHETIC_PROMPT','NOT_BOT_CHAT','SECRET','accountId']:
self.assertNotIn(forbidden,json.dumps(public))
serialized = json.dumps(projected)
for private in ['PRIVATE_MEMORY', 'PRIVATE_WHISPER', 'SYNTHETIC_PROMPT', 'NOT_BOT_CHAT', 'HumanPlayer', 'SECRET', 'accountId', 'internal-guid']:
self.assertNotIn(private, serialized)
self.assertEqual([b['faction'] for b in projected['bots']], ['Alliance', 'Horde'])
self.assertEqual([e['text'] for e in projected['feed']], ['Hello friend'])
def test_http_allowlist_and_mutations(self):
server = m.Server(('127.0.0.1', 0), m.Handler)
worker = threading.Thread(target=server.serve_forever, daemon=True)
worker.start()
base = 'http://127.0.0.1:' + str(server.server_port)
try:
for route in ['/app.js', '/style.css', '/health']:
with urllib.request.urlopen(base + route) as response:
self.assertEqual(response.status, 200)
self.assertIn("default-src 'none'", response.headers['Content-Security-Policy'])
for path in ['/api/actions', '/api/ale/operator', '/.dev.vars', '/../.dev.vars', '/%2e%2e/.dev.vars', '/api/bridge/ale/poll', '/assets/../../.dev.vars', '/assets/private.map']:
with self.assertRaises(urllib.error.HTTPError) as error:
urllib.request.urlopen(base + path)
self.assertEqual(error.exception.code, 404)
error.exception.close()
with patch.object(m,'local_get',return_value=b'<html>Visitor</html>') as upstream:
with urllib.request.urlopen(base+'/') as response:
self.assertEqual(response.read(),b'<html>Visitor</html>')
upstream.assert_called_once_with('/visitor')
with patch.object(m.SNAPSHOT,'get',return_value={'state':{'bots':[]}}):
with urllib.request.urlopen(base+'/api/state') as response:
self.assertEqual(json.load(response),{'bots':[]})
for method in ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'TRACE']:
with self.assertRaises(urllib.error.HTTPError) as error:
urllib.request.urlopen(urllib.request.Request(base + '/api/actions', method=method, data=b'{}'))
self.assertEqual(error.exception.code, 405)
error.exception.close()
finally:
server.shutdown()
server.server_close()
worker.join()
if __name__ == '__main__':
unittest.main()
+46
View File
@@ -0,0 +1,46 @@
'use strict';
const $ = id => document.getElementById(id);
let realm;
function el(tag, text, cls) { const node = document.createElement(tag); if (text !== undefined) node.textContent = String(text); if (cls) node.className = cls; return node; }
function empty(node, text) { node.replaceChildren(el('p', text, 'empty')); }
function renderRoster() {
if (!realm) return;
const term = $('search').value.trim().toLowerCase(), faction = $('faction').value;
const bots = realm.bots.filter(b => (faction === 'all' || b.faction === faction) && [b.name,b.class,b.race,b.zone,b.guild].join(' ').toLowerCase().includes(term));
$('roster').replaceChildren(...bots.map(b => {
const row = el('tr'); row.append(el('td', b.name));
const team = el('td'); team.append(el('span', b.faction, 'tag ' + b.faction.toLowerCase())); row.append(team);
const role = el('td', b.class); role.append(el('small', b.race)); row.append(role, el('td', b.level), el('td', b.zone || 'Unknown'), el('td', b.guild || '—')); return row;
}));
$('roster-empty').hidden = bots.length > 0;
$('roster-empty').textContent = realm.bots.length ? 'No residents match this search.' : 'No residents are currently reported online.';
}
function renderFeed() {
if (!realm) return;
const entries = realm.feed.filter(e => $('activity-filter').value === 'all' || e.kind === $('activity-filter').value);
if (!entries.length) return empty($('feed'), 'No confirmed activity of this type yet. New encounters will appear here.');
$('feed').replaceChildren(...entries.map(e => {
const node = el('article', undefined, 'entry');
node.append(el('span', e.kind + ' · ' + new Date(e.at).toLocaleString(), 'meta'), el('h3', e.names.join(' ↔ ')), el('p', e.text)); return node;
}));
}
function render() {
$('total').textContent = realm.bots.length;
$('alliance').textContent = realm.bots.filter(b => b.faction === 'Alliance').length;
$('horde').textContent = realm.bots.filter(b => b.faction === 'Horde').length;
$('parties-count').textContent = realm.parties.length;
$('status').className = 'status' + (realm.stale ? ' stale' : '');
$('status').textContent = (realm.stale ? 'Updates delayed · Showing last known roster' : 'Live realm observations') + (realm.rosterAt ? ' · Observed ' + new Date(realm.rosterAt * 1000).toLocaleTimeString() : '');
renderRoster(); renderFeed();
$('parties').replaceChildren(...realm.parties.map(p => { const n=el('article',undefined,'group');n.append(el('strong',p.members.join(' · ')),el('p',p.faction));return n; }));
if (!realm.parties.length) empty($('parties'), 'No bot parties observed yet.');
const goals={quest:'Complete quests together',support:'Help guildmates in combat',explore:'Explore other maps'};
$('guilds').replaceChildren(...realm.guilds.map(g => { const n=el('article',undefined,'group');n.append(el('strong',g.name),el('p',goals[g.goal]||'Shared adventures'),el('p',g.progress+' contributions · milestone '+g.target));const p=el('progress');p.max=Math.max(1,g.target);p.value=g.progress;p.setAttribute('aria-label',g.name+' goal progress');n.append(p);return n; }));
if (!realm.guilds.length) empty($('guilds'), 'Waiting for guild observations.');
}
async function refresh() {
try { const response=await fetch('/api/realm',{cache:'no-store',signal:AbortSignal.timeout(10000)});if(!response.ok)throw new Error();realm=await response.json();render(); }
catch { $('status').className='status stale';$('status').textContent=realm?'Connection interrupted · Showing the last observation. Retrying automatically.':'Realm temporarily unavailable. Retrying automatically.'; }
finally { setTimeout(refresh,15000); }
}
$('search').addEventListener('input',renderRoster);$('faction').addEventListener('change',renderRoster);$('activity-filter').addEventListener('change',renderFeed);refresh();
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>AzerothAI · Living Realm</title><link rel="stylesheet" href="/style.css"><script src="/app.js" defer></script></head>
<body><main>
<header><div><p class="eyebrow">AZEROTHAI / SPECTATOR</p><h1>A world with a life of its own.</h1><p class="intro">Follow the residents of Azeroth as they adventure, meet, and build a shared history.</p></div><span class="badge">View only</span></header>
<div class="status" role="status" id="status">Connecting to the realm…</div>
<section class="stats" aria-label="Realm population"><article><span>Residents online</span><strong id="total">—</strong><small>50 resident limit</small></article><article class="alliance"><span>Alliance</span><strong id="alliance">—</strong><small>25 resident target</small></article><article class="horde"><span>Horde</span><strong id="horde">—</strong><small>25 resident target</small></article><article><span>Adventuring parties</span><strong id="parties-count">—</strong><small>Observed bot groups</small></article></section>
<nav aria-label="Realm sections"><a href="#residents">Residents</a><a href="#activity">Social life</a><a href="#groups">Parties & guilds</a></nav>
<section id="residents" class="panel"><div class="heading"><div><p class="eyebrow">IN THE WORLD</p><h2>Meet the residents</h2></div><div class="filters"><label><span class="sr-only">Search residents</span><input id="search" type="search" placeholder="Find a name, class, or zone…"></label><label><span class="sr-only">Faction</span><select id="faction"><option value="all">Both factions</option><option>Alliance</option><option>Horde</option></select></label></div></div><div class="table-wrap"><table><thead><tr><th>Name</th><th>Faction</th><th>Class / race</th><th>Level</th><th>Location</th><th>Guild</th></tr></thead><tbody id="roster"></tbody></table></div><p id="roster-empty" class="empty">Waiting for the first observation.</p></section>
<div class="columns"><section id="activity" class="panel"><div class="heading"><div><p class="eyebrow">SHARED STORIES</p><h2>Social life</h2></div><label><span class="sr-only">Activity type</span><select id="activity-filter"><option value="all">All activity</option><option value="conversation">Conversations</option><option value="relationship">Relationships</option></select></label></div><div id="feed" class="feed"></div></section><section id="groups" class="panel"><div class="heading"><div><p class="eyebrow">BETTER TOGETHER</p><h2>Parties & guilds</h2></div></div><div class="group-body"><h3>Current parties</h3><div id="parties"></div><h3 class="guild-title">Guild ambitions</h3><div id="guilds"></div></div></section></div>
<footer>Automatic updates every 15 seconds · Population can briefly dip while a replacement joins.<br>Bot-to-bot conversations only. Player whispers stay private.</footer>
</main></body></html>
+1
View File
@@ -0,0 +1 @@
:root{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#e6e8ed;background:#0b1017;font-synthesis:none}*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 15% 0,#23314455,transparent 55%)}main{max-width:1280px;margin:auto;padding:48px 28px}header{display:flex;justify-content:space-between;gap:24px;align-items:flex-start}h1{font-family:Georgia,serif;font-size:clamp(30px,4vw,48px);font-weight:400;letter-spacing:-1px;margin:12px 0}h2{font-size:22px;margin:5px 0}h3{font-size:15px;margin:0 0 14px}.eyebrow{font-size:11px;letter-spacing:2px;color:#c3a56f;margin:0}.intro{max-width:640px;color:#a9b2c1;line-height:1.7}.badge{border:1px solid #415245;border-radius:30px;color:#a8c8a5;white-space:nowrap;padding:8px 14px;font-size:12px}.status{color:#a9b2c1;margin:20px 0;font-size:13px}.status.stale{color:#f2bd79}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:16px}.stats article{background:#121a25;border:1px solid #293241;border-radius:14px;padding:22px;display:flex;flex-direction:column;gap:6px}.stats span{font-size:13px;color:#bec7d3}.stats strong{font-size:40px;font-weight:500;color:#dcc18e}.stats small{font-size:11px;color:#8e9aaa}.stats .alliance strong{color:#8dbbff}.stats .horde strong{color:#ef9995}nav{display:flex;gap:25px;padding:26px 0}a{color:#bcc7d5;text-decoration:none;font-size:14px}a:hover{color:#efd69f}.panel{background:#111923;border:1px solid #293241;border-radius:14px;overflow:hidden;scroll-margin-top:20px}.heading{padding:24px;display:flex;align-items:center;justify-content:space-between;gap:18px;border-bottom:1px solid #27303c}.filters{display:flex;gap:8px}input,select{font:inherit;font-size:13px;background:#0c131c;border:1px solid #354153;color:#dfe5ec;padding:11px;border-radius:7px}input{width:250px}input:focus,select:focus,a:focus-visible{outline:2px solid #c3a56f;outline-offset:3px}.table-wrap{overflow-x:auto}table{width:100%;border-collapse:collapse;text-align:left;white-space:nowrap}th{font-size:10px;text-transform:uppercase;letter-spacing:1px;color:#919eaf;padding:15px 22px}td{font-size:13px;padding:15px 22px;border-top:1px solid #202b38;color:#b5c0ce}td:first-child{color:#eee6d9;font-weight:600}td small{display:block;color:#8b98aa;font-size:11px;margin-top:4px}.tag{font-size:11px;border-radius:4px;padding:4px 7px;background:#263347;color:#a6c8fb}.tag.horde{background:#38282e;color:#eab0a7}.columns{display:grid;grid-template-columns:1.25fr 1fr;gap:22px;margin-top:22px;align-items:start}.feed{max-height:660px;overflow-y:auto;padding:0 24px}.entry{padding:22px 0;border-bottom:1px solid #293241}.entry:last-child{border:0}.entry .meta{font-size:10px;color:#a6b0bd;text-transform:uppercase;letter-spacing:.8px}.entry h3{margin:10px 0;color:#d8c19a}.entry p{font-size:14px;line-height:1.7;white-space:pre-wrap;margin:0;color:#b6c1d0;overflow-wrap:anywhere}.group-body{padding:24px}.group{background:#17212e;border-radius:9px;padding:16px;margin-bottom:12px;font-size:14px;line-height:1.6}.group strong{display:block;color:#dfcfb2}.group p{font-size:12px;color:#aab7c8;margin:5px 0}.guild-title{margin-top:30px}progress{width:100%;height:6px;accent-color:#bea573}.empty{color:#95a2b4;font-size:13px;line-height:1.7;padding:20px;margin:0}footer{margin-top:30px;color:#8593a6;font-size:12px;line-height:1.9}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:850px){.columns{grid-template-columns:1fr}.heading{align-items:flex-start;flex-direction:column}.filters{width:100%}input{width:100%}.filters label:first-child{flex:1}}@media(max-width:540px){main{padding:28px 16px}.stats{grid-template-columns:1fr 1fr;gap:10px}.stats article{padding:17px}.stats strong{font-size:34px}header{display:block}.badge{display:inline-block;margin-top:8px}.heading{padding:20px}.filters{flex-direction:column}select{max-width:100%}nav{gap:20px}nav a{font-size:13px}}
+31
View File
@@ -0,0 +1,31 @@
const {test}=require('node:test');
const assert=require('node:assert/strict');
const ale=require('../.test-build/ale-store.js');
const llm=require('../.test-build/llm/index.js');
const {runLiveAutonomyTick}=require('../.test-build/autonomy.js');
test('inference preserves intervening observations and cancels after retirement',async()=>{
const now=Date.now();
const bot={id:'hero',name:'Zaku',status:'online',tier:'hero',decisions:0,
personality:{activities:['explore']},autonomy:{managed:true},level:5,zone:'Old zone'};
let state={revision:1,bots:[bot],events:[],sequence:0,settings:{}};
const store={load:async()=>structuredClone(state),save:async(s,revision)=>{
assert.equal(revision,state.revision);state=s;
}};
ale.recentAleEvents=async()=>[{at:new Date(now).toISOString(),kind:'player.snapshot',payload:{name:'Zaku',isBot:true,level:5,zoneName:'Old zone'}}];
let queued=0,retire=false;
ale.queueAleCommand=async()=>{queued++;return {id:'intent'};};
llm.providerForBot=()=>({route:{provider:'ollama',model:'test'},provider:{decide:async()=>{
state.revision++;
state.bots[0].level=6;state.bots[0].zone='New zone';
state.bots[0].autonomy.observedAt=new Date(now+1000).toISOString();
if(retire)state.bots[0].status='retired';
return {action:'explore',goal:'Explore nearby',reason:'Curious'};
}}});
assert.equal((await runLiveAutonomyTick(store,{}, {},{now})).ran,true);
assert.equal(state.bots[0].level,6);assert.equal(state.bots[0].zone,'New zone');
assert.equal(queued,1);
retire=true;
assert.equal((await runLiveAutonomyTick(store,{}, {},{now,forceBotId:'hero'})).ran,false);
assert.equal(queued,1);
});
+39
View File
@@ -0,0 +1,39 @@
const {test}=require('node:test');
const assert=require('node:assert/strict');
const {partyDecision,bondEvidence,community,compileCommunityAction,observePresence,applyCooperation}=require('../.test-build/community.js');
const now=()=>Date.now()/1000;
function bot(id){return {id,name:id,status:'online',level:5,autonomy:{managed:true,characterGuid:id==='A'?1:2},personality:{sociability:60,activities:['quest']},relationships:[],memories:[],world:{at:now(),team:1,nearby:id==='A'?['B']:['A'],inCombat:false,groupNames:[],groupId:'',guildId:3,guildName:'Guild',guildRank:0,mapId:0,x:1,y:1}};}
test('party acceptance respects faction, level, hostility, combat, and observation freshness',()=>{
const a=bot('A'),b=bot('B');assert.equal(partyDecision(a,b).accepted,true);
b.world.team=0;assert.equal(partyDecision(a,b).accepted,false);b.world.team=1;
b.level=55;assert.equal(partyDecision(a,b).accepted,false);b.level=5;
b.relationships=[{targetId:'A',affinity:-10}];assert.equal(partyDecision(a,b).accepted,false);b.relationships=[];
b.world.at=now()-150;assert.equal(partyDecision(a,b).accepted,false);b.world.at=now();
b.world.inCombat=true;assert.equal(partyDecision(a,b).accepted,false);
});
test('relationship evidence is reciprocal, replay safe, bounded, and excludes deaths',()=>{
const a=bot('A'),b=bot('B'),s={bots:[a,b]};
bondEvidence(s,'x',a,b,'Helped',3);bondEvidence(s,'x',a,b,'Helped',3);
assert.equal(a.relationships[0].affinity,3);assert.equal(b.relationships[0].affinity,3);
applyCooperation(s,{observedAt:now(),evidenceId:'q',characterGuid:1,peerGuid:2,cooperation:'quest',questId:5});
assert.equal(a.relationships[0].affinity,6);assert.equal(a.memories.length,1);
applyCooperation(s,{observedAt:now(),evidenceId:'q',characterGuid:1,peerGuid:2,cooperation:'quest',questId:5});assert.equal(a.memories.length,1);
b.status='dead';applyCooperation(s,{observedAt:now(),evidenceId:'q2',characterGuid:1,peerGuid:2,cooperation:'quest'});assert.equal(a.memories.length,1);
});
test('native commands protect human parties, cap party size, verify identities and enforce guild authority',()=>{
const a=bot('A'),b=bot('B');
const p=compileCommunityAction('party',a,b);assert.match(p,/Human party is protected/);assert.match(p,/GetMembersCount\(\)>=5/);assert.match(p,/GroupCreate/);assert.match(p,/GetGUIDLow\(\)~=2/);
const g=compileCommunityAction('recruit',a,b);assert.match(g,/GetGuildRank\(\)~=0/);assert.match(g,/Already belongs to a guild/);
assert.match(compileCommunityAction('leave',a),/Party changed/);
});
test('fresh movement snapshots update guild registry and stale snapshots cannot create stuck alerts',()=>{
const a=bot('A'),s={bots:[a]};a.world.at=now()-30;
observePresence(s,a,{observedAt:now(),team:1,x:10,y:20,mapId:0,guildId:3,guildName:'Guild',groupId:'',nearbyBots:'B'});
assert.equal(community(s).guilds[0].name,'Guild');const at=a.world.at;
observePresence(s,a,{observedAt:now()-500,team:1,x:2,y:2});assert.equal(a.world.at,at);
});
test('offline party members are protected when their identity cannot be checked live',()=>{
assert.match(compileCommunityAction('party',bot('A'),bot('B')),/Party has offline members/);
assert.match(compileCommunityAction('leave',bot('A')),/Party has offline members/);
});
+77
View File
@@ -0,0 +1,77 @@
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');
require('../.test-build/community.js').communityTick=async()=>{};
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');
assert.equal(a.relationships[0].affinity,1);assert.equal(b.relationships[0].affinity,1);
await runConversationTick(store,{},{});
assert.equal(a.memories.length,1);assert.equal(b.memories.length,1);assert.equal(followups,1);
}finally{global.fetch=originalFetch;}
});
+26
View File
@@ -3,6 +3,7 @@ const assert=require('node:assert/strict');
const {execute}=require('../.test-build/service.js'); const {execute}=require('../.test-build/service.js');
const {seed}=require('../.test-build/seed.js'); const {seed}=require('../.test-build/seed.js');
const {connectors}=require('../.test-build/connectors/index.js'); const {connectors}=require('../.test-build/connectors/index.js');
const {applyAuthoritativeAleEvent}=require('../.test-build/service.js');
class Store { class Store {
constructor(){this.state=seed();} constructor(){this.state=seed();}
@@ -10,6 +11,18 @@ class Store {
async save(s,r){assert.equal(this.state.revision,r);s.revision=r+1;this.state=structuredClone(s);} async save(s,r){assert.equal(this.state.revision,r);s.revision=r+1;this.state=structuredClone(s);}
} }
test('game lifecycle death closes identity once and preserves Zaku',async()=>{
const store=new Store();
const bot=store.state.bots.find(b=>b.id==='bot-6');
const payload={name:bot.name,isBot:true,characterGuid:99,accountId:2,deathId:'hardcore-99'};
await applyAuthoritativeAleEvent(store,'player.permanent-death',payload);
assert.equal(store.state.bots.find(b=>b.id===bot.id).status,'dead');
await applyAuthoritativeAleEvent(store,'player.permanent-death',payload);
assert.equal(store.state.events.filter(e=>e.kind==='death').length,1);
await applyAuthoritativeAleEvent(store,'player.permanent-death',{name:'Zaku',isBot:true,characterGuid:9,accountId:1});
assert.notEqual(store.state.bots.find(b=>b.name==='Zaku').status,'dead');
});
test('Zaku is a mortal persistent hero and hardcore starts enabled',()=>{ test('Zaku is a mortal persistent hero and hardcore starts enabled',()=>{
const state=seed(); const state=seed();
const zaku=state.bots.find(b=>b.id==='hero-zaku'); const zaku=state.bots.find(b=>b.id==='hero-zaku');
@@ -66,3 +79,16 @@ test('unverified bridge death fails safe without erasing identity',async()=>{
assert.equal(bot.death,undefined); assert.equal(bot.death,undefined);
}finally{connectors.simulation=old;} }finally{connectors.simulation=old;}
}); });
test('complete roster clears stale presence and historical events cannot restore it',async()=>{
const store=new Store();
const payload={name:'Rosterbot',isBot:true,characterGuid:123,accountId:2,className:'Death Knight',raceName:'Orc',level:55};
await applyAuthoritativeAleEvent(store,'player.snapshot',payload);
await applyAuthoritativeAleEvent(store,'player.snapshot',{roster:true,observedAt:100,characterGuids:[123]});
assert.equal(store.state.bots.filter(b=>b.status==='online').length,1);
await applyAuthoritativeAleEvent(store,'player.snapshot',{roster:true,observedAt:101,characterGuids:[]});
for(const kind of ['player.login','player.snapshot','player.zone'])await applyAuthoritativeAleEvent(store,kind,payload);
assert.equal(store.state.bots.filter(b=>b.status==='online').length,0);
await applyAuthoritativeAleEvent(store,'player.snapshot',{roster:true,observedAt:100,characterGuids:[123]});
assert.equal(store.state.bots.filter(b=>b.status==='online').length,0);
});
+133
View File
@@ -0,0 +1,133 @@
"""Run with python3 after installing lupa; no live server or database involved."""
from pathlib import Path
from lupa import LuaRuntime
lua = LuaRuntime()
setup='''
rows={{10,"Dead",1,0,"dead",2},{11,"Zaku",1,1,"active",5},
{12,"Fresh",2,0,"reserve",2},{13,"Human",3,0,"active",1},
{14,"Wronglevel",2,0,"reserve",2}}
players={}; writes={}; removed={}; values={}; events={}; deaths=0
function player(guid,name,account,level,dead)
local p={guid=guid,name=name,account=account,level=level,dead=dead}
function p:GetGUIDLow() return self.guid end
function p:GetName() return self.name end
function p:GetAccountId() return self.account end
function p:GetLevel() return self.level end
function p:GetClass() return self.class or 1 end
function p:GetRace() return self.race or 2 end
function p:IsDead() return self.dead end
players[name]=p; return p
end
function AuthDBQuery(sql)
if string.find(sql,'SELECT id FROM account',1,true) then
if string.find(sql,'id=99 ',1,true) then return nil end
return {GetUInt32=function() return 2 end}
end
local q={i=1}
function q:GetUInt32(n) return rows[self.i][n+1] end
function q:GetString(n) return rows[self.i][n+1] end
function q:NextRow() self.i=self.i+1;return self.i<=#rows end
return q
end
function CharDBQuery(sql) return {GetUInt32=function() return 0 end} end
function AuthDBExecute(sql) table.insert(writes,sql) end
mgr={}
function mgr:Remove(p) table.insert(removed,p.guid);players[p.name]=nil end
function mgr:SetValue(guid,key,value) values[guid..key]=value end
function mgr:GetValue(guid,key) return values[guid..key] or 0 end
function GetRandomPlayerbotMgr() return mgr end
function GetPlayerByName(name) return players[name] end
function GetPlayersInWorld() return players end
function RegisterPlayerEvent(id,fn) events[id]=fn end
function CreateLuaEvent(fn) tick=fn end
function PrintError(text) end
function PrintInfo(text) end
function AzerothAIEmitPermanentDeath(p) deaths=deaths+1 end
function AzerothAIEmitPlayerSnapshot(name) end
player(10,"Dead",1,80,false)
player(11,"Zaku",1,7,true)
player(12,"Fresh",2,1,false)
-- A mismatched account must never be managed, even if the GUID/name matches.
player(13,"Human",99,20,true)
player(14,"Wronglevel",2,55,false)
'''
lua.execute(setup)
lua.execute((Path(__file__).parents[1] / 'ale/azerothai_lifecycle.lua').read_text())
lua.execute('''
assert(#removed==1 and removed[1]==10)
assert(values['10logout']==1 and values['10add']==0)
assert(players.Zaku and players.Human)
assert(deaths==0)
tick();assert(not players.Wronglevel)
local fresh=players.Fresh
events[8](8,nil,fresh);events[8](8,nil,fresh);events[6](6,nil,fresh)
assert(deaths==1,'Repeated death events must be idempotent')
tick();assert(not players.Fresh and values['12logout']==1)
events[8](8,nil,players.Zaku);events[8](8,nil,players.Human)
assert(deaths==1 and players.Zaku and players.Human)
local before=#writes;tick();assert(#writes==before,'No per-second database churn')
-- A retired identity accidentally logged in again must be evicted.
player(10,'Dead',1,80,false);tick();assert(not players.Dead)
player(15,'Factorynew',2,1,false);tick();assert(players.Factorynew)
player(16,'Realhuman',99,1,true);tick();assert(players.Realhuman)
assert(deaths==1)
events[8](8,nil,players.Factorynew);tick();assert(not players.Factorynew)
assert(deaths==2)
local fast=player(17,'Fastlearner',2,1,false)
events[3](3,fast);fast.level=2;tick();assert(players.Fastlearner)
''')
balanced=LuaRuntime()
balanced.execute(setup)
balanced.execute('''
players={};rows={}
for i=1,25 do
rows[#rows+1]={100+i,'Horde'..i,2,0,'active',2}
player(100+i,'Horde'..i,2,5,false)
end
rows[#rows+1]={201,'Alliancefresh',2,0,'reserve',1}
rows[#rows+1]={202,'Hordefresh',2,0,'reserve',2}
player(201,'Alliancefresh',2,1,false)
player(202,'Hordefresh',2,1,false)
''')
balanced.execute((Path(__file__).parents[1] / 'ale/azerothai_lifecycle.lua').read_text())
balanced.execute('''
assert(players.Alliancefresh,'Underrepresented faction must fill its vacancy')
assert(not players.Hordefresh,'Do not exceed 25 members of a faction')
assert(values['202logout']==1)
assert(deaths==0,'Faction deferral is not a death')
''')
returning=LuaRuntime()
returning.execute(setup)
returning.execute("""
players={};rows={}
for i=1,26 do
rows[#rows+1]={100+i,'Horde'..i,2,0,'active',2}
player(100+i,'Horde'..i,2,5,false)
end
rows[#rows+1]={999,'Zaku',2,1,'active',2}
player(999,'Zaku',2,7,false)
""")
returning.execute((Path(__file__).parents[1] / 'ale/azerothai_lifecycle.lua').read_text())
returning.execute("""
local n=0;for _ in pairs(players) do n=n+1 end
assert(n==25 and players.Zaku,'Returning population must obey faction cap and protect Zaku')
assert(deaths==0,'Holding returning residents offline must not retire them')
assert(values['125logout']==1 and values['126logout']==1)
""")
dk=LuaRuntime()
dk.execute(setup)
dk.execute("""
players={};rows={{201,'AllianceDK',2,0,'reserve',1},{202,'HordeDK',2,0,'reserve',2}}
local a=player(201,'AllianceDK',2,55,false);a.class=6;a.race=1
local h=player(202,'HordeDK',2,55,false);h.class=6
""")
dk.execute((Path(__file__).parents[1] / 'ale/azerothai_lifecycle.lua').read_text())
dk.execute("""
assert(players.AllianceDK and players.HordeDK)
local entries=0
for _,q in pairs(writes) do if string.find(q,'entry_level=55',1,true) then entries=entries+1 end end
assert(entries==2,'Death Knights must be audited at level 55')
events[8](8,nil,players.HordeDK);tick();assert(not players.HordeDK and deaths==1)
""")
print('PASS: retirement, protection, level-1 entry, replay safety, and 25/25 faction balancing')