Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
210b715a6f | ||
|
|
7e19d82a02 | ||
|
|
8e96c7ff47 | ||
|
|
46935c791e | ||
|
|
5bee808492 | ||
|
|
b0a661f425 | ||
|
|
ee8161eca2 | ||
|
|
ce6b38f3bb | ||
|
|
360f164905 | ||
|
|
77f6b56c2f | ||
|
|
bc50395221 | ||
|
|
c5f84a7221 | ||
|
|
86d57ec9ae |
@@ -18,6 +18,5 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: node scripts/test-core.mjs
|
||||
|
||||
@@ -18,7 +18,6 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Core and simulation tests
|
||||
run: node scripts/test-core.mjs
|
||||
@@ -62,7 +61,6 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Install Ollama
|
||||
run: curl -fsSL https://ollama.com/install.sh | sh
|
||||
|
||||
@@ -31,7 +31,6 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
@@ -47,6 +47,11 @@ local function json_value(value)
|
||||
if value == nil then return "null" end
|
||||
if t == "boolean" then return value and "true" or "false" 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) .. '"'
|
||||
end
|
||||
|
||||
@@ -85,6 +90,11 @@ local function emit(kind, payload)
|
||||
AuthDBExecute(sql)
|
||||
end
|
||||
|
||||
local function area_name(id)
|
||||
local ok,name=pcall(GetAreaName,id)
|
||||
return ok and name or tostring(id)
|
||||
end
|
||||
|
||||
local function player_payload(player)
|
||||
if not player then return {} end
|
||||
local isBot = is_playerbot(player)
|
||||
@@ -96,12 +106,15 @@ local function player_payload(player)
|
||||
mapId=player:GetMapId(),
|
||||
zoneId=player:GetZoneId(),
|
||||
areaId=player:GetAreaId(),
|
||||
zoneName=GetAreaName(player:GetZoneId()) or tostring(player:GetZoneId()),
|
||||
zoneName=area_name(player:GetZoneId()),
|
||||
race=player:GetRace(),
|
||||
class=player:GetClass(),
|
||||
raceName=player:GetRaceAsString() or tostring(player:GetRace()),
|
||||
className=player:GetClassAsString() or tostring(player:GetClass()),
|
||||
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
|
||||
}
|
||||
end
|
||||
@@ -128,14 +141,31 @@ local function nearby_bot_names(player)
|
||||
return table.concat(names,",")
|
||||
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)
|
||||
if not name or name == "" then return 0 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
|
||||
emit("player.snapshot",player_payload(player))
|
||||
emit("player.snapshot",world_payload(player))
|
||||
return 1
|
||||
end
|
||||
|
||||
function AzerothAIEmitPermanentDeath(player)
|
||||
local payload=player_payload(player)
|
||||
payload.deathId="hardcore-"..tostring(player:GetGUIDLow())
|
||||
emit("player.permanent-death",payload)
|
||||
end
|
||||
|
||||
local function mark_consumed(id)
|
||||
AuthDBExecute("UPDATE azerothai_command_inbox SET consumed_at=NOW() WHERE id=" .. db_text(to_hex(id)))
|
||||
end
|
||||
@@ -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.
|
||||
-- Expose a safe global refresh hook too, so the autonomy worker can explicitly
|
||||
-- request a fresh authoritative snapshot if the periodic timer ever stalls.
|
||||
local socialLast={}
|
||||
function AzerothAIEmitSnapshots()
|
||||
local ok,players=pcall(function() return GetPlayersInWorld() end)
|
||||
if not ok or type(players)~="table" then return 0 end
|
||||
local emitted=0
|
||||
local guids={}
|
||||
for _,player in pairs(players) do
|
||||
if is_playerbot(player) then
|
||||
emit("player.snapshot",player_payload(player))
|
||||
guids[#guids+1]=player:GetGUIDLow()
|
||||
emitted=emitted+1
|
||||
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
|
||||
end
|
||||
|
||||
@@ -254,7 +307,7 @@ RegisterPlayerEvent(27, function(event, player, newZone, newArea)
|
||||
local payload=player_payload(player)
|
||||
payload.zone=newZone
|
||||
payload.area=newArea
|
||||
payload.zoneName=GetAreaName(newZone) or tostring(newZone)
|
||||
payload.zoneName=area_name(newZone)
|
||||
emit("player.zone",payload)
|
||||
end)
|
||||
|
||||
@@ -343,3 +396,40 @@ end
|
||||
|
||||
emit("bridge.ready",{pollMs=POLL_MS,protectedCharacter=PROTECTED})
|
||||
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)
|
||||
|
||||
@@ -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")
|
||||
@@ -2,7 +2,7 @@ import { env } from 'cloudflare:workers';
|
||||
import { z } from 'zod';
|
||||
import { requireAleBridge } from '@/core/ale-auth';
|
||||
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 { applyAuthoritativeAleEvent,execute } from '@/core/service';
|
||||
import type { RuntimeConfig } from '@/core/types';
|
||||
@@ -25,6 +25,7 @@ const STATEFUL_EVENTS=new Set([
|
||||
'player.achievement',
|
||||
'player.close-call',
|
||||
'player.killed-by-creature',
|
||||
'player.permanent-death',
|
||||
'player.repop',
|
||||
'player.resurrect'
|
||||
]);
|
||||
@@ -41,6 +42,8 @@ export async function POST(request:Request){
|
||||
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();
|
||||
if(STATEFUL_EVENTS.has(body.kind)){
|
||||
await applyAuthoritativeAleEvent(store,body.kind,body.payload);
|
||||
|
||||
@@ -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});}
|
||||
}
|
||||
@@ -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></>;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+22
-18
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
|
||||
'use client';
|
||||
import Home from '../page';
|
||||
export default function Visitor(){return <Home readOnly/>;}
|
||||
+21
-7
@@ -103,8 +103,8 @@ export async function runLiveAutonomyTick(
|
||||
options:{forceBotId?:string;now?:number}={}
|
||||
){
|
||||
const now=options.now??Date.now();
|
||||
const s=await store.load();
|
||||
const revision=s.revision;
|
||||
let s=await store.load();
|
||||
let revision=s.revision;
|
||||
const events=await recentAleEvents(db,400);
|
||||
|
||||
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'};
|
||||
}
|
||||
|
||||
const bot=pick.bot;
|
||||
let bot=pick.bot;
|
||||
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);
|
||||
let decision:Decision;
|
||||
let usedFallback=false;
|
||||
try{
|
||||
decision=await brain.provider.decide(bot,observation);
|
||||
}catch(error){
|
||||
if(brain.route.provider==='deterministic')throw error;
|
||||
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 queued=await queueAleCommand(db,source);
|
||||
|
||||
bot.lastDecision=decision;
|
||||
bot.decisions++;
|
||||
bot.goal=decision.goal;
|
||||
bot.level=observation.level;
|
||||
bot.zone=observation.zone;
|
||||
const newerObservation=Date.parse(bot.autonomy?.observedAt??'')>Date.parse(pick.obs.at);
|
||||
if(!newerObservation){bot.level=observation.level;bot.zone=observation.zone;}
|
||||
bot.autonomy={
|
||||
...(bot.autonomy??{}),
|
||||
managed:true,
|
||||
observedAt:pick.obs.at,
|
||||
observedAt:newerObservation?bot.autonomy?.observedAt:pick.obs.at,
|
||||
lastIntentAt:new Date(now).toISOString(),
|
||||
nextIntentAt:new Date(now+nextIntervalMs(bot)).toISOString(),
|
||||
lastAleCommandId:queued.id
|
||||
|
||||
@@ -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'}));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export type ConversationChannel='say'|'whisper';
|
||||
|
||||
export type ConversationRequest={
|
||||
socialTurn?:0|1;
|
||||
id:string;
|
||||
createdAt:string;
|
||||
speakerName:string;
|
||||
@@ -10,7 +11,7 @@ export type ConversationRequest={
|
||||
receiver:string;
|
||||
selectedPlayer:string;
|
||||
nearbyBots:string[];
|
||||
status:'pending'|'processing'|'completed'|'failed';
|
||||
status:'pending'|'processing'|'awaiting_delivery'|'completed'|'failed';
|
||||
attempts:number;
|
||||
};
|
||||
|
||||
@@ -79,7 +80,7 @@ export async function leaseConversation(db:D1Database):Promise<ConversationReque
|
||||
.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
|
||||
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;
|
||||
|
||||
const startedAt=new Date().toISOString();
|
||||
@@ -89,6 +90,7 @@ export async function leaseConversation(db:D1Database):Promise<ConversationReque
|
||||
if(updated.meta.changes!==1)return null;
|
||||
|
||||
return {
|
||||
socialTurn:row.channel==='social-start'?0:row.channel==='social-reply'?1:undefined,
|
||||
id:row.id,
|
||||
createdAt:row.created_at,
|
||||
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<?`)
|
||||
.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
@@ -1,7 +1,8 @@
|
||||
import {bondEvidence,socialEntry,communityTick} from './community';
|
||||
import { z } from 'zod';
|
||||
import { queueAleCommand } from './ale-store';
|
||||
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';
|
||||
|
||||
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;
|
||||
return state.bots.find(bot=>
|
||||
bot.autonomy?.managed===true&&
|
||||
bot.status!=='dead'&&bot.status!=='retired'&&
|
||||
bot.status==='online'&&
|
||||
bot.name.toLowerCase()===lower
|
||||
);
|
||||
}
|
||||
@@ -54,14 +55,8 @@ function chooseTarget(state:State,request:ConversationRequest){
|
||||
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){
|
||||
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 hero=bot.tier==='hero';
|
||||
@@ -79,7 +74,7 @@ async function modelReply(bot:Bot,request:ConversationRequest,state:State,config
|
||||
content:JSON.stringify({
|
||||
speaker:request.speakerName,
|
||||
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,
|
||||
identity:{name:bot.name,race:bot.race,role:bot.role,tier:bot.tier},
|
||||
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);
|
||||
}
|
||||
|
||||
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=[
|
||||
`local bot=GetPlayerByName(${luaString(botName)})`,
|
||||
'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'){
|
||||
source.push(`local receiver=GetPlayerByName(${luaString(speakerName)})`);
|
||||
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 bot=state.bots.find(x=>x.id===botId);
|
||||
if(!bot)return;
|
||||
if(bot.memories.some(memory=>memory.id===request.id))return;
|
||||
bot.memories.push({
|
||||
id:crypto.randomUUID(),
|
||||
id:request.id,
|
||||
text:`${request.speakerName} said: "${request.message.slice(0,180)}" I replied: "${reply.slice(0,180)}"`,
|
||||
importance:request.channel==='whisper'?7:6,
|
||||
source:'observation',
|
||||
at:new Date().toISOString()
|
||||
});
|
||||
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';
|
||||
realmEvent(state,'memory',`${bot.name} had a live conversation with ${request.speakerName}.`,bot.id);
|
||||
try{
|
||||
@@ -153,10 +162,24 @@ async function remember(store:StateStore,botId:string,request:ConversationReques
|
||||
}
|
||||
|
||||
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);
|
||||
if(!request)return {ok:true,ran:false,reason:'No player conversation waiting'};
|
||||
|
||||
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 bot=chooseTarget(state,request);
|
||||
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};
|
||||
}
|
||||
|
||||
let reply:string;
|
||||
try{
|
||||
reply=await modelReply(bot,request,state,config);
|
||||
}catch{
|
||||
reply=fallbackReply(bot,request);
|
||||
}
|
||||
let reply=await modelReply(bot,request,state,config);
|
||||
reply=cleanReply(reply);
|
||||
if(!reply)throw new Error('Conversation reply was empty');
|
||||
|
||||
const command=await queueAleCommand(db,compileReply(bot.name,request.speakerName,reply,request.channel));
|
||||
await remember(store,bot.id,request,reply);
|
||||
await completeConversation(db,request.id,JSON.stringify({bot:bot.name,reply,commandId:command.id}));
|
||||
const command=await queueAleCommand(db,compileReply(bot.name,request.speakerName,reply,request.channel,request.socialTurn!==undefined));
|
||||
await awaitDelivery(db,request,bot.id,reply,command.id);
|
||||
await cleanupConversations(db);
|
||||
|
||||
return {ok:true,ran:true,speaker:request.speakerName,name:bot.name,channel:request.channel,reply,commandId:command.id};
|
||||
|
||||
+34
-6
@@ -1,3 +1,4 @@
|
||||
import {community,observePresence,applyCooperation,socialEntry} from './community';
|
||||
import { runGM } from './gm';
|
||||
import { actionSchema } from './validation';
|
||||
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>){
|
||||
let s=await store.load();
|
||||
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'){
|
||||
let changed=false;
|
||||
for(const bot of s.bots){
|
||||
@@ -103,7 +123,7 @@ export async function applyAuthoritativeAleEvent(store:StateStore,kind:string,pa
|
||||
const isZaku=name.toLowerCase()==='zaku';
|
||||
b={
|
||||
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,
|
||||
simulationBand:'background',
|
||||
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(kind==='player.snapshot')observePresence(s,b,payload);
|
||||
b.level=level;
|
||||
if(zone)b.zone=zone;
|
||||
|
||||
if(kind==='player.login'){
|
||||
b.status='online';
|
||||
if(!s.aleRosterAt)b.status='online';
|
||||
b.simulationBand='foreground';
|
||||
event(s,'lifecycle',`${b.name} logged into the authoritative AzerothCore world.`,b.id);
|
||||
}else if(kind==='player.logout'){
|
||||
b.status='offline';
|
||||
if(!s.aleRosterAt)b.status='offline';
|
||||
b.simulationBand='dormant';
|
||||
event(s,'lifecycle',`${b.name} logged out of the authoritative AzerothCore world.`,b.id);
|
||||
}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';
|
||||
event(s,'lifecycle',`${b.name} is now in ${b.zone} (authoritative ALE observation).`,b.id);
|
||||
}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';
|
||||
}else if(kind==='player.level'){
|
||||
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);
|
||||
}
|
||||
event(s,'memory',`${b.name} survived a close call in ${b.zone}; the experience was committed to memory.`,b.id);
|
||||
}else if(kind==='player.killed-by-creature'){
|
||||
}else if(kind==='player.killed-by-creature'||kind==='player.permanent-death'){
|
||||
if(b.plotArmorHpFloor>=1){
|
||||
event(s,'error',`Plot armor breach: authoritative ALE reported ${b.name} dead. Worldserver damage enforcement must prevent this transition.`,b.id);
|
||||
}else if(s.settings.hardcore.enabled){
|
||||
@@ -213,6 +234,13 @@ export async function execute(store:StateStore,input:unknown,config:RuntimeConfi
|
||||
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);
|
||||
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'){
|
||||
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);
|
||||
|
||||
+6
-2
@@ -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 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 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 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};
|
||||
@@ -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 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 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 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}>;}
|
||||
|
||||
@@ -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 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',[
|
||||
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('lifecycle'),botId:z.string(),action:z.enum(['spawn','pause','resume','despawn','retire'])}),
|
||||
z.object({type:z.literal('think'),botId:z.string()}),
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Back up the realm, seed its lifecycle registry, and install the ALE guard.
|
||||
|
||||
Run as the Docker-authorized realm owner, with --apply after reviewing the default read-only plan.
|
||||
The world server must be stopped for --apply. This script never deletes characters.
|
||||
"""
|
||||
import argparse
|
||||
import datetime
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.request
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--lab', type=Path, required=True)
|
||||
parser.add_argument('--apply', action='store_true')
|
||||
args = parser.parse_args()
|
||||
repo = Path(__file__).resolve().parents[2]
|
||||
lab = args.lab.resolve()
|
||||
config = {}
|
||||
for line in (lab / 'runtime.env').read_text().splitlines():
|
||||
if '=' in line and not line.startswith('#'):
|
||||
key, value = line.split('=', 1)
|
||||
config[key] = value.strip().strip('"').strip("'")
|
||||
env = os.environ.copy()
|
||||
env['MYSQL_PWD'] = config['DOCKER_DB_ROOT_PASSWORD']
|
||||
|
||||
def mysql(sql):
|
||||
return subprocess.check_output(['docker', 'exec', '-e', 'MYSQL_PWD',
|
||||
'ac-database', 'mysql', '-uroot', '-N', '-B', '-e', sql], env=env, text=True)
|
||||
|
||||
state = json.load(urllib.request.urlopen('http://127.0.0.1:8787/api/state', timeout=10))
|
||||
assert state['settings']['hardcore']['enabled'], 'Enable hardcore before installing the guard'
|
||||
raw = mysql("SELECT c.guid,c.name,c.account,c.level,c.totaltime,c.class FROM acore_characters.characters c "
|
||||
"JOIN acore_auth.account a ON a.id=c.account WHERE a.username REGEXP '^RNDBOT[0-9]+$'")
|
||||
characters = [line.split('\t') for line in raw.splitlines() if line]
|
||||
managed = {b.get('autonomy', {}).get('characterGuid'): b for b in state['bots']
|
||||
if b.get('autonomy', {}).get('managed')}
|
||||
rows = []
|
||||
counts = {}
|
||||
for guid, name, account, level, played, cls in characters:
|
||||
guid, account = int(guid), int(account)
|
||||
bot = managed.get(guid)
|
||||
if bot:
|
||||
assert bot['name'] == name and bot['autonomy']['accountId'] == account, 'Identity mismatch'
|
||||
protected = name.lower() == 'zaku' or bool(bot and bot.get('plotArmorHpFloor', 0) > 0)
|
||||
if protected:
|
||||
status = 'active'
|
||||
elif bot and bot['status'] == 'dead':
|
||||
assert bot.get('death', {}).get('verified'), 'Unverified death cannot retire a character'
|
||||
status = 'dead'
|
||||
elif bot and bot['status'] != 'retired':
|
||||
status = 'active'
|
||||
elif int(level) == (55 if int(cls) == 6 else 1) and int(played) == 0:
|
||||
status = 'reserve'
|
||||
else:
|
||||
status = 'blocked'
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
rows.append((guid, name, account, int(protected), status))
|
||||
assert any(name.lower() == 'zaku' and protected for _, name, _, protected, _ in rows)
|
||||
assert counts.get('reserve', 0) >= counts.get('dead', 0), 'Insufficient unused starting-level reserve'
|
||||
print('Lifecycle plan:', json.dumps(counts), flush=True)
|
||||
if not args.apply:
|
||||
raise SystemExit(0)
|
||||
running = json.loads(subprocess.check_output(['docker', 'inspect', 'ac-worldserver']))[0]['State']['Running']
|
||||
assert not running, 'Stop ac-worldserver before applying this migration'
|
||||
os.umask(0o077)
|
||||
backup = lab / ('lifecycle-backup-' + datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ'))
|
||||
backup.mkdir()
|
||||
(backup / 'realm-state.json').write_text(json.dumps(state))
|
||||
(backup / 'registry-plan.json').write_text(json.dumps(rows))
|
||||
dump = subprocess.Popen(['docker', 'exec', '-e', 'MYSQL_PWD', 'ac-database',
|
||||
'mysqldump', '-uroot', '--single-transaction', '--no-tablespaces', '--set-gtid-purged=OFF',
|
||||
'--databases', 'acore_auth', 'acore_characters', 'acore_playerbots'], env=env, stdout=subprocess.PIPE)
|
||||
with gzip.open(backup / 'realm.sql.gz', 'wb') as stream:
|
||||
shutil.copyfileobj(dump.stdout, stream)
|
||||
assert dump.wait() == 0, 'Database backup failed; no migration applied'
|
||||
core = lab / 'azerothcore'
|
||||
override = core / 'docker-compose.override.yml'
|
||||
shutil.copy2(override, backup / override.name)
|
||||
shutil.copytree(core / 'azerothai-lua', backup / 'azerothai-lua')
|
||||
mysql('''CREATE TABLE IF NOT EXISTS acore_auth.azerothai_lifecycle (
|
||||
guid INT UNSIGNED PRIMARY KEY, name VARCHAR(64) NOT NULL,
|
||||
account_id INT UNSIGNED NOT NULL, protected TINYINT NOT NULL DEFAULT 0,
|
||||
state VARCHAR(16) NOT NULL, died_at TIMESTAMP NULL, activated_at TIMESTAMP NULL,
|
||||
race TINYINT UNSIGNED NOT NULL DEFAULT 0, entry_level TINYINT UNSIGNED NULL
|
||||
) ENGINE=InnoDB''')
|
||||
for column, definition in [('race', 'TINYINT UNSIGNED NOT NULL DEFAULT 0'), ('entry_level', 'TINYINT UNSIGNED NULL')]:
|
||||
if not mysql(f"SHOW COLUMNS FROM acore_auth.azerothai_lifecycle LIKE '{column}'").strip():
|
||||
mysql(f'ALTER TABLE acore_auth.azerothai_lifecycle ADD COLUMN {column} {definition}')
|
||||
def text(value):
|
||||
return "CONVERT(UNHEX('" + value.encode().hex() + "') USING utf8mb4)"
|
||||
# Never overwrite existing ledger history on a repeated installation.
|
||||
values = [f"({guid},{text(name)},{account},{protected},{text(status)},"
|
||||
+ ("NOW())" if status == 'dead' else "NULL)")
|
||||
for guid, name, account, protected, status in rows]
|
||||
mysql("INSERT IGNORE INTO acore_auth.azerothai_lifecycle "
|
||||
"(guid,name,account_id,protected,state,died_at) VALUES " + ','.join(values))
|
||||
mysql("UPDATE acore_auth.azerothai_lifecycle l JOIN acore_characters.characters c "
|
||||
"ON c.guid=l.guid AND c.account=l.account_id AND c.name=l.name SET l.race=c.race")
|
||||
# The native factory separates manual AddClass accounts from autonomous accounts.
|
||||
# Make the verified unused reserve selectable without increasing the online cap.
|
||||
mysql("UPDATE acore_playerbots.playerbots_account_type t "
|
||||
"JOIN acore_auth.azerothai_lifecycle l ON l.account_id=t.account_id "
|
||||
"SET t.account_type=1,t.assignment_date=NOW() WHERE l.state='reserve'")
|
||||
source = (repo / 'ale/azerothai_bridge.lua').read_text()
|
||||
current = (core / 'azerothai-lua/azerothai_bridge.lua').read_text()
|
||||
for key in ['POLL_MS', 'PROTECTED', 'KNOWN_BOT_ACCOUNTS']:
|
||||
pattern = r'^local ' + key + r' = .*$'
|
||||
line = re.search(pattern, current, re.M)
|
||||
assert line, key
|
||||
source, n = re.subn(pattern, lambda match: line.group(0), source, flags=re.M)
|
||||
assert n == 1, key
|
||||
(core / 'azerothai-lua/azerothai_bridge.lua').write_text(source)
|
||||
shutil.copy2(repo / 'ale/azerothai_lifecycle.lua', core / 'azerothai-lua/azerothai_lifecycle.lua')
|
||||
settings = {'AC_AI_PLAYERBOT_DISABLE_RANDOM_LEVELS': '1',
|
||||
'AC_AI_PLAYERBOT_RANDOMBOT_STARTING_LEVEL': '1',
|
||||
'AC_AI_PLAYERBOT_DISABLE_DEATH_KNIGHT_LOGIN': '0'}
|
||||
content = override.read_text()
|
||||
for key, value in settings.items():
|
||||
pattern = r'(?m)^(\s*)' + key + r':.*$'
|
||||
if re.search(pattern, content):
|
||||
content = re.sub(pattern, lambda m: m.group(1) + key + ': "' + value + '"', content)
|
||||
else:
|
||||
assert ' environment:\n' in content, 'Unexpected compose environment layout'
|
||||
content = content.replace(' environment:\n', ' environment:\n ' + key + ': "' + value + '"\n', 1)
|
||||
override.write_text(content)
|
||||
print('Migration installed. Backup:', backup, flush=True)
|
||||
print('Recreate ac-worldserver to activate; verify the lifecycle startup line and population counts.', flush=True)
|
||||
@@ -0,0 +1,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.')
|
||||
@@ -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.')
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -9,11 +9,11 @@
|
||||
"install:ci": "bash scripts/install-pnpm.sh",
|
||||
"dev": "node scripts/run-framework.mjs dev",
|
||||
"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",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"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": {
|
||||
"@base-ui/react": "^1.7.0",
|
||||
|
||||
@@ -93,12 +93,20 @@ function rows(sql){
|
||||
}
|
||||
|
||||
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)
|
||||
FROM acore_auth.azerothai_event_outbox
|
||||
WHERE delivered_at IS NULL
|
||||
ORDER BY CASE WHEN kind IN ('player.whisper','player.chat') THEN 0 ELSE 1 END, id
|
||||
LIMIT ${EVENT_BATCH}`);
|
||||
for(const [id,kindHex,payloadHex] of batch){
|
||||
${fresh.length?`AND id NOT IN (${fresh.map(row=>Number(row[0])).join(',')})`:''}
|
||||
ORDER BY CASE WHEN kind IN ('player.permanent-death','bridge.ready') THEN 0 WHEN kind IN ('player.whisper','player.chat') AND created_at >= NOW() - INTERVAL 90 SECOND THEN 1 ELSE 2 END, id
|
||||
LIMIT ${Math.max(1,EVENT_BATCH-fresh.length)}`);
|
||||
for(const [id,kindHex,payloadHex] of [...fresh,...batch]){
|
||||
const kind=Buffer.from(kindHex||'','hex').toString('utf8');
|
||||
const payloadText=Buffer.from(payloadHex||'','hex').toString('utf8');
|
||||
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)}`);
|
||||
if(EVENT_GAP_MS)await sleep(EVENT_GAP_MS);
|
||||
}
|
||||
return batch.length;
|
||||
return fresh.length+batch.length;
|
||||
}
|
||||
|
||||
async function deliverResults(){
|
||||
@@ -136,6 +144,14 @@ async function deliverResults(){
|
||||
|
||||
function cleanup(){
|
||||
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
|
||||
SET delivered_at=NOW(6)
|
||||
WHERE delivered_at IS NULL
|
||||
@@ -151,10 +167,10 @@ console.log(`AzerothAI ALE DB relay started: ${BASE}, poll ${POLL_MS}ms`);
|
||||
|
||||
while(!stopping){
|
||||
try{
|
||||
if(Date.now()-lastCleanup>60_000){cleanup();lastCleanup=Date.now();}
|
||||
await deliverEvents();
|
||||
await deliverResults();
|
||||
await pullCommand();
|
||||
if(Date.now()-lastCleanup>60_000){cleanup();lastCleanup=Date.now();}
|
||||
await sleep(POLL_MS);
|
||||
}catch(error){
|
||||
console.error(new Date().toISOString(),error instanceof Error?error.message:String(error));
|
||||
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -0,0 +1,28 @@
|
||||
// Run the built worker without Wrangler's development proxy or file watcher.
|
||||
import './sites-env.mjs';
|
||||
import {createRequire} from 'node:module';
|
||||
import path from 'node:path';
|
||||
import {readdirSync} from 'node:fs';
|
||||
import {unstable_getMiniflareWorkerOptions} from 'wrangler';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const wranglerRequire = createRequire(require.resolve('wrangler'));
|
||||
const {Miniflare} = wranglerRequire('miniflare');
|
||||
const config = path.resolve('dist/server/wrangler.json');
|
||||
const {workerOptions, externalWorkers, main} = unstable_getMiniflareWorkerOptions(config);
|
||||
if (!main) throw new Error('Build the application before starting the local runtime');
|
||||
const root = path.dirname(main);
|
||||
const modules = readdirSync(root, {recursive: true})
|
||||
.filter(file => /\.(m?js)$/.test(file))
|
||||
.map(file => ({type: 'ESModule', path: path.join(root, file)}));
|
||||
modules.sort((a, b) => a.path === main ? -1 : b.path === main ? 1 : 0);
|
||||
const runtime = new Miniflare({
|
||||
host: process.env.RUNTIME_HOST || (process.argv.includes('--lan') ? '0.0.0.0' : '127.0.0.1'),
|
||||
port: Number(process.env.RUNTIME_PORT || 8787),
|
||||
d1Persist: path.resolve('.wrangler/state/v3/d1'),
|
||||
workers: [{...workerOptions, modules, modulesRoot: root}, ...externalWorkers],
|
||||
});
|
||||
console.log(`AzerothAI local runtime ready: ${await runtime.ready}`);
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.once(signal, async () => { await runtime.dispose(); process.exit(0); });
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {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);
|
||||
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);
|
||||
|
||||
@@ -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()
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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}}
|
||||
@@ -0,0 +1,31 @@
|
||||
const {test}=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const ale=require('../.test-build/ale-store.js');
|
||||
const llm=require('../.test-build/llm/index.js');
|
||||
const {runLiveAutonomyTick}=require('../.test-build/autonomy.js');
|
||||
|
||||
test('inference preserves intervening observations and cancels after retirement',async()=>{
|
||||
const now=Date.now();
|
||||
const bot={id:'hero',name:'Zaku',status:'online',tier:'hero',decisions:0,
|
||||
personality:{activities:['explore']},autonomy:{managed:true},level:5,zone:'Old zone'};
|
||||
let state={revision:1,bots:[bot],events:[],sequence:0,settings:{}};
|
||||
const store={load:async()=>structuredClone(state),save:async(s,revision)=>{
|
||||
assert.equal(revision,state.revision);state=s;
|
||||
}};
|
||||
ale.recentAleEvents=async()=>[{at:new Date(now).toISOString(),kind:'player.snapshot',payload:{name:'Zaku',isBot:true,level:5,zoneName:'Old zone'}}];
|
||||
let queued=0,retire=false;
|
||||
ale.queueAleCommand=async()=>{queued++;return {id:'intent'};};
|
||||
llm.providerForBot=()=>({route:{provider:'ollama',model:'test'},provider:{decide:async()=>{
|
||||
state.revision++;
|
||||
state.bots[0].level=6;state.bots[0].zone='New zone';
|
||||
state.bots[0].autonomy.observedAt=new Date(now+1000).toISOString();
|
||||
if(retire)state.bots[0].status='retired';
|
||||
return {action:'explore',goal:'Explore nearby',reason:'Curious'};
|
||||
}}});
|
||||
assert.equal((await runLiveAutonomyTick(store,{}, {},{now})).ran,true);
|
||||
assert.equal(state.bots[0].level,6);assert.equal(state.bots[0].zone,'New zone');
|
||||
assert.equal(queued,1);
|
||||
retire=true;
|
||||
assert.equal((await runLiveAutonomyTick(store,{}, {},{now,forceBotId:'hero'})).ran,false);
|
||||
assert.equal(queued,1);
|
||||
});
|
||||
@@ -0,0 +1,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/);
|
||||
});
|
||||
@@ -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;}
|
||||
});
|
||||
@@ -3,6 +3,7 @@ const assert=require('node:assert/strict');
|
||||
const {execute}=require('../.test-build/service.js');
|
||||
const {seed}=require('../.test-build/seed.js');
|
||||
const {connectors}=require('../.test-build/connectors/index.js');
|
||||
const {applyAuthoritativeAleEvent}=require('../.test-build/service.js');
|
||||
|
||||
class Store {
|
||||
constructor(){this.state=seed();}
|
||||
@@ -10,6 +11,18 @@ class Store {
|
||||
async save(s,r){assert.equal(this.state.revision,r);s.revision=r+1;this.state=structuredClone(s);}
|
||||
}
|
||||
|
||||
test('game lifecycle death closes identity once and preserves Zaku',async()=>{
|
||||
const store=new Store();
|
||||
const bot=store.state.bots.find(b=>b.id==='bot-6');
|
||||
const payload={name:bot.name,isBot:true,characterGuid:99,accountId:2,deathId:'hardcore-99'};
|
||||
await applyAuthoritativeAleEvent(store,'player.permanent-death',payload);
|
||||
assert.equal(store.state.bots.find(b=>b.id===bot.id).status,'dead');
|
||||
await applyAuthoritativeAleEvent(store,'player.permanent-death',payload);
|
||||
assert.equal(store.state.events.filter(e=>e.kind==='death').length,1);
|
||||
await applyAuthoritativeAleEvent(store,'player.permanent-death',{name:'Zaku',isBot:true,characterGuid:9,accountId:1});
|
||||
assert.notEqual(store.state.bots.find(b=>b.name==='Zaku').status,'dead');
|
||||
});
|
||||
|
||||
test('Zaku is a mortal persistent hero and hardcore starts enabled',()=>{
|
||||
const state=seed();
|
||||
const zaku=state.bots.find(b=>b.id==='hero-zaku');
|
||||
@@ -66,3 +79,16 @@ test('unverified bridge death fails safe without erasing identity',async()=>{
|
||||
assert.equal(bot.death,undefined);
|
||||
}finally{connectors.simulation=old;}
|
||||
});
|
||||
|
||||
test('complete roster clears stale presence and historical events cannot restore it',async()=>{
|
||||
const store=new Store();
|
||||
const payload={name:'Rosterbot',isBot:true,characterGuid:123,accountId:2,className:'Death Knight',raceName:'Orc',level:55};
|
||||
await applyAuthoritativeAleEvent(store,'player.snapshot',payload);
|
||||
await applyAuthoritativeAleEvent(store,'player.snapshot',{roster:true,observedAt:100,characterGuids:[123]});
|
||||
assert.equal(store.state.bots.filter(b=>b.status==='online').length,1);
|
||||
await applyAuthoritativeAleEvent(store,'player.snapshot',{roster:true,observedAt:101,characterGuids:[]});
|
||||
for(const kind of ['player.login','player.snapshot','player.zone'])await applyAuthoritativeAleEvent(store,kind,payload);
|
||||
assert.equal(store.state.bots.filter(b=>b.status==='online').length,0);
|
||||
await applyAuthoritativeAleEvent(store,'player.snapshot',{roster:true,observedAt:100,characterGuids:[123]});
|
||||
assert.equal(store.state.bots.filter(b=>b.status==='online').length,0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Run with python3 after installing lupa; no live server or database involved."""
|
||||
from pathlib import Path
|
||||
from lupa import LuaRuntime
|
||||
|
||||
lua = LuaRuntime()
|
||||
setup='''
|
||||
rows={{10,"Dead",1,0,"dead",2},{11,"Zaku",1,1,"active",5},
|
||||
{12,"Fresh",2,0,"reserve",2},{13,"Human",3,0,"active",1},
|
||||
{14,"Wronglevel",2,0,"reserve",2}}
|
||||
players={}; writes={}; removed={}; values={}; events={}; deaths=0
|
||||
function player(guid,name,account,level,dead)
|
||||
local p={guid=guid,name=name,account=account,level=level,dead=dead}
|
||||
function p:GetGUIDLow() return self.guid end
|
||||
function p:GetName() return self.name end
|
||||
function p:GetAccountId() return self.account end
|
||||
function p:GetLevel() return self.level end
|
||||
function p:GetClass() return self.class or 1 end
|
||||
function p:GetRace() return self.race or 2 end
|
||||
function p:IsDead() return self.dead end
|
||||
players[name]=p; return p
|
||||
end
|
||||
function AuthDBQuery(sql)
|
||||
if string.find(sql,'SELECT id FROM account',1,true) then
|
||||
if string.find(sql,'id=99 ',1,true) then return nil end
|
||||
return {GetUInt32=function() return 2 end}
|
||||
end
|
||||
local q={i=1}
|
||||
function q:GetUInt32(n) return rows[self.i][n+1] end
|
||||
function q:GetString(n) return rows[self.i][n+1] end
|
||||
function q:NextRow() self.i=self.i+1;return self.i<=#rows end
|
||||
return q
|
||||
end
|
||||
function CharDBQuery(sql) return {GetUInt32=function() return 0 end} end
|
||||
function AuthDBExecute(sql) table.insert(writes,sql) end
|
||||
mgr={}
|
||||
function mgr:Remove(p) table.insert(removed,p.guid);players[p.name]=nil end
|
||||
function mgr:SetValue(guid,key,value) values[guid..key]=value end
|
||||
function mgr:GetValue(guid,key) return values[guid..key] or 0 end
|
||||
function GetRandomPlayerbotMgr() return mgr end
|
||||
function GetPlayerByName(name) return players[name] end
|
||||
function GetPlayersInWorld() return players end
|
||||
function RegisterPlayerEvent(id,fn) events[id]=fn end
|
||||
function CreateLuaEvent(fn) tick=fn end
|
||||
function PrintError(text) end
|
||||
function PrintInfo(text) end
|
||||
function AzerothAIEmitPermanentDeath(p) deaths=deaths+1 end
|
||||
function AzerothAIEmitPlayerSnapshot(name) end
|
||||
player(10,"Dead",1,80,false)
|
||||
player(11,"Zaku",1,7,true)
|
||||
player(12,"Fresh",2,1,false)
|
||||
-- A mismatched account must never be managed, even if the GUID/name matches.
|
||||
player(13,"Human",99,20,true)
|
||||
player(14,"Wronglevel",2,55,false)
|
||||
'''
|
||||
lua.execute(setup)
|
||||
lua.execute((Path(__file__).parents[1] / 'ale/azerothai_lifecycle.lua').read_text())
|
||||
lua.execute('''
|
||||
assert(#removed==1 and removed[1]==10)
|
||||
assert(values['10logout']==1 and values['10add']==0)
|
||||
assert(players.Zaku and players.Human)
|
||||
assert(deaths==0)
|
||||
tick();assert(not players.Wronglevel)
|
||||
local fresh=players.Fresh
|
||||
events[8](8,nil,fresh);events[8](8,nil,fresh);events[6](6,nil,fresh)
|
||||
assert(deaths==1,'Repeated death events must be idempotent')
|
||||
tick();assert(not players.Fresh and values['12logout']==1)
|
||||
events[8](8,nil,players.Zaku);events[8](8,nil,players.Human)
|
||||
assert(deaths==1 and players.Zaku and players.Human)
|
||||
local before=#writes;tick();assert(#writes==before,'No per-second database churn')
|
||||
-- A retired identity accidentally logged in again must be evicted.
|
||||
player(10,'Dead',1,80,false);tick();assert(not players.Dead)
|
||||
player(15,'Factorynew',2,1,false);tick();assert(players.Factorynew)
|
||||
player(16,'Realhuman',99,1,true);tick();assert(players.Realhuman)
|
||||
assert(deaths==1)
|
||||
events[8](8,nil,players.Factorynew);tick();assert(not players.Factorynew)
|
||||
assert(deaths==2)
|
||||
local fast=player(17,'Fastlearner',2,1,false)
|
||||
events[3](3,fast);fast.level=2;tick();assert(players.Fastlearner)
|
||||
''')
|
||||
balanced=LuaRuntime()
|
||||
balanced.execute(setup)
|
||||
balanced.execute('''
|
||||
players={};rows={}
|
||||
for i=1,25 do
|
||||
rows[#rows+1]={100+i,'Horde'..i,2,0,'active',2}
|
||||
player(100+i,'Horde'..i,2,5,false)
|
||||
end
|
||||
rows[#rows+1]={201,'Alliancefresh',2,0,'reserve',1}
|
||||
rows[#rows+1]={202,'Hordefresh',2,0,'reserve',2}
|
||||
player(201,'Alliancefresh',2,1,false)
|
||||
player(202,'Hordefresh',2,1,false)
|
||||
''')
|
||||
balanced.execute((Path(__file__).parents[1] / 'ale/azerothai_lifecycle.lua').read_text())
|
||||
balanced.execute('''
|
||||
assert(players.Alliancefresh,'Underrepresented faction must fill its vacancy')
|
||||
assert(not players.Hordefresh,'Do not exceed 25 members of a faction')
|
||||
assert(values['202logout']==1)
|
||||
assert(deaths==0,'Faction deferral is not a death')
|
||||
''')
|
||||
returning=LuaRuntime()
|
||||
returning.execute(setup)
|
||||
returning.execute("""
|
||||
players={};rows={}
|
||||
for i=1,26 do
|
||||
rows[#rows+1]={100+i,'Horde'..i,2,0,'active',2}
|
||||
player(100+i,'Horde'..i,2,5,false)
|
||||
end
|
||||
rows[#rows+1]={999,'Zaku',2,1,'active',2}
|
||||
player(999,'Zaku',2,7,false)
|
||||
""")
|
||||
returning.execute((Path(__file__).parents[1] / 'ale/azerothai_lifecycle.lua').read_text())
|
||||
returning.execute("""
|
||||
local n=0;for _ in pairs(players) do n=n+1 end
|
||||
assert(n==25 and players.Zaku,'Returning population must obey faction cap and protect Zaku')
|
||||
assert(deaths==0,'Holding returning residents offline must not retire them')
|
||||
assert(values['125logout']==1 and values['126logout']==1)
|
||||
""")
|
||||
dk=LuaRuntime()
|
||||
dk.execute(setup)
|
||||
dk.execute("""
|
||||
players={};rows={{201,'AllianceDK',2,0,'reserve',1},{202,'HordeDK',2,0,'reserve',2}}
|
||||
local a=player(201,'AllianceDK',2,55,false);a.class=6;a.race=1
|
||||
local h=player(202,'HordeDK',2,55,false);h.class=6
|
||||
""")
|
||||
dk.execute((Path(__file__).parents[1] / 'ale/azerothai_lifecycle.lua').read_text())
|
||||
dk.execute("""
|
||||
assert(players.AllianceDK and players.HordeDK)
|
||||
local entries=0
|
||||
for _,q in pairs(writes) do if string.find(q,'entry_level=55',1,true) then entries=entries+1 end end
|
||||
assert(entries==2,'Death Knights must be audited at level 55')
|
||||
events[8](8,nil,players.HordeDK);tick();assert(not players.HordeDK and deaths==1)
|
||||
""")
|
||||
print('PASS: retirement, protection, level-1 entry, replay safety, and 25/25 faction balancing')
|
||||
Reference in New Issue
Block a user