diff --git a/.github/workflows/core-tests.yml b/.github/workflows/core-tests.yml index 3a6c4c8..745ab8d 100644 --- a/.github/workflows/core-tests.yml +++ b/.github/workflows/core-tests.yml @@ -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 diff --git a/.github/workflows/full-verification.yml b/.github/workflows/full-verification.yml index 68df0e1..15e6b17 100644 --- a/.github/workflows/full-verification.yml +++ b/.github/workflows/full-verification.yml @@ -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 diff --git a/.github/workflows/realm-simulation.yml b/.github/workflows/realm-simulation.yml index fa1e6ae..3c68cf9 100644 --- a/.github/workflows/realm-simulation.yml +++ b/.github/workflows/realm-simulation.yml @@ -31,7 +31,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - cache: pnpm - run: pnpm install --frozen-lockfile diff --git a/ale/azerothai_bridge.lua b/ale/azerothai_bridge.lua index 0fd512a..98c3a33 100644 --- a/ale/azerothai_bridge.lua +++ b/ale/azerothai_bridge.lua @@ -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) diff --git a/ale/azerothai_lifecycle.lua b/ale/azerothai_lifecycle.lua new file mode 100644 index 0000000..e4d303e --- /dev/null +++ b/ale/azerothai_lifecycle.lua @@ -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.guid0 + if allowed then slots[team]=slots[team]-1 end + if gates[guid]~=allowed or not gateAt[guid] or os.time()-gateAt[guid]>300 then + mgr:SetValue(guid,"logout",allowed and 0 or 1,"AzerothAI faction capacity") + gates[guid]=allowed + gateAt[guid]=os.time() + end + if not allowed and mgr:GetValue(guid,"add")~=0 then mgr:SetValue(guid,"add",0) end + end +end + +-- Record death synchronously in memory; logout happens outside the death hook. +RegisterPlayerEvent(8,function(event,killer,killed) recordDeath(killed) end) +RegisterPlayerEvent(6,function(event,killer,killed) recordDeath(killed) end) +RegisterPlayerEvent(3,function(event,player) + local row,guid=identity(player) + if row and row.state=="reserve" then arrivals[guid]=player:GetLevel() end +end) +CreateLuaEvent(function() + local ok,err=pcall(enforce) + if not ok then PrintError("[AzerothAI] Lifecycle enforcement failed: "..tostring(err)) end +end,1000,0) + +reload() +enforce() +PrintInfo("[AzerothAI] Persistent hardcore retirement and reserve replacement enabled") diff --git a/app/api/bridge/ale/event/route.ts b/app/api/bridge/ale/event/route.ts index 758a74b..48847ca 100644 --- a/app/api/bridge/ale/event/route.ts +++ b/app/api/bridge/ale/event/route.ts @@ -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); diff --git a/app/api/operations/route.ts b/app/api/operations/route.ts new file mode 100644 index 0000000..cf71942 --- /dev/null +++ b/app/api/operations/route.ts @@ -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_at0)").bind(before).run(); + return Response.json({ok:true}); + }catch(e){return Response.json({error:e instanceof Error?e.message:'Invalid telemetry'},{status:400});} +} diff --git a/app/community-panel.tsx b/app/community-panel.tsx new file mode 100644 index 0000000..909f207 --- /dev/null +++ b/app/community-panel.tsx @@ -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;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>((groups,b)=>{(groups[b.world!.groupId]??=[]).push(b);return groups;},{})); + return <> +

Cooperative play

Maximum 50 residents. Group and guild changes appear as confirmed only after the game accepts them.

+ + +
+

Live parties

{parties.length?parties.map(([id,bots])=>
{bots?.map(b=>b.name).join(', ')}

{bots?.[0]?.zone} · {bots?.length} observed members

):

No parties currently observed.

}
+

Guild goals

{c?.guilds.length?c.guilds.map(g=>
{g.name}

{g.progress} confirmed contributions · milestone {g.target}

):

Waiting for guild observations.

}
+

Social activity

{[...(c?.feed??[])].reverse().filter(e=>filter==='all'||e.kind===filter).map(e=>
{new Date(e.at).toLocaleString()} · {e.kind} · {e.status??'observed'}

{e.names.join(' ↔ ')}

{e.text}

{e.affinityDelta!==undefined&&Trust change: {e.affinityDelta>0?'+':''}{e.affinityDelta}}
)}{!c?.feed.length&&

No confirmed social activity recorded yet.

}
+ ; +} +export function OperationsPanel({state,act,busy}:{state:State;act:(a:any)=>Promise;busy:boolean}){ + const [data,setData]=useState(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 <>
{['ubuntu','windows'].map(host=>{const s=data?.samples.find((s:any)=>s.host===host),stale=!s||Date.now()/1000-s.at>180;return

{host==='ubuntu'?'Ubuntu server':'Windows PC'}

{s?<>

{stale?'Telemetry stale — values may have changed':'Current telemetry'} · {new Date(s.at*1000).toLocaleTimeString()}

{[['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)=>
{label}{value}
)}{s.memoryAvailableMB<1024&&

Memory pressure: keep the population capped at 50.

}

{s.maintenance}

:

Waiting for telemetry from this machine.

}
;})}
+

Storage and stalled activity

{error&&

{error}

}

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.

Possible stalled bots

Stationary for over 10 minutes while intending to quest or explore. This is a review signal, not proof of being stuck; no automatic teleporting.

{data?.stuck?.length?data.stuck.map((b:any)=>

{b.name} · {b.zone} · {b.minutes} minutes

):

No candidates detected.

}
; +} diff --git a/app/globals.css b/app/globals.css index 99802d5..2bc31c3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -6,3 +6,5 @@ *{box-sizing:border-box}body{margin:0;background:var(--background);color:var(--foreground);font-family:Arial,Helvetica,sans-serif;font-size:16px}button,input,textarea{font:inherit}button{cursor:pointer}button:disabled{cursor:not-allowed}h1,h2,h3,p{margin:0}h1{font:500 34px Georgia,serif;letter-spacing:.01em}h2{font-size:17px;font-weight:600}h3{font-size:16px;font-weight:600}small{font-size:13px;color:var(--muted)}svg{flex-shrink:0}.app-main{flex:1;min-width:0}.brand{display:flex;align-items:center;gap:12px;padding:27px 22px 25px}.brand-mark{font:38px Georgia,serif;color:#e3bb70}.brand strong{font:26px Georgia,serif;color:#e3bb70}.brand small{display:block;letter-spacing:2px;font-size:10px;margin-top:6px}.nav-section{color:#6f829b;letter-spacing:1.8px;font-size:11px;padding:24px 16px 10px}.nav-button{height:45px!important;margin-bottom:4px;padding:0 14px!important;font-size:14px!important}.nav-button svg{width:18px!important;height:18px!important}.sidebar-bottom{border-top:1px solid var(--border);padding:20px;margin:16px 8px 0;line-height:1.6;font-size:13px;color:var(--muted)}.topbar{height:65px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--border);padding:0 32px;background:#0d1622}.crumb{color:var(--muted);font-size:14px;display:flex;align-items:center;gap:12px}.top-right{display:flex;align-items:center;gap:16px;font-size:13px}.sim-badge{color:#e3bb70;border:1px solid #715b30;background:#30271b;padding:5px 10px;border-radius:5px;font-size:12px;white-space:nowrap}.content{padding:30px 32px;max-width:1800px;margin:auto}.page-heading{display:flex;justify-content:space-between;gap:20px;align-items:center;margin-bottom:26px}.eyebrow{font-size:11px;letter-spacing:2px;color:#c7a667;text-transform:uppercase;margin-bottom:9px}.subtitle{color:var(--muted);font-size:14px;margin-top:9px;line-height:1.6}.actions{display:flex;align-items:center;gap:9px;flex-wrap:wrap}.panel{background:linear-gradient(125deg,#121e2d,#0e1824);border:1px solid var(--border);border-radius:8px;overflow:hidden}.panel-head{padding:18px 21px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-bottom:1px solid var(--border)}.panel-head h2{display:flex;gap:10px;align-items:center}.panel-head svg{color:#93a9c8;width:18px}.panel-body{padding:21px}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:23px}.stat{padding:21px;position:relative}.stat-label{font-size:14px;color:var(--muted);display:flex;align-items:center;justify-content:space-between}.stat-value{font-size:32px;font-weight:600;margin:13px 0 7px;font-variant-numeric:tabular-nums}.stat small{font-size:12px}.stat svg{color:#7aa4df;width:20px}.dashboard-grid{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(280px,1fr);gap:22px}.span-all{grid-column:1/-1}.director-strip{background:linear-gradient(110deg,#243143,#141f2c);border-color:#526072;display:flex;justify-content:space-between;align-items:center;gap:20px;padding:24px;margin-bottom:23px}.director-strip h2{font:22px Georgia,serif;margin-bottom:9px}.director-strip p{font-size:14px;line-height:1.6;color:#b0bfd1}.director-icon{width:48px;height:48px;color:#e3bb70;flex-shrink:0}.director-content{display:flex;align-items:center;gap:20px}.tag{display:inline-block;border:1px solid #314a67;border-radius:4px;padding:3px 7px;font-size:12px;color:#9cbdee;background:#192b42}.tag.online{color:#80d9af;border-color:#285e49;background:#15372d}.tag.paused{color:#e0bf78;border-color:#5a4c2d;background:#302a1f}.tag.offline,.tag.retired{color:#a1aec1;background:#202937;border-color:#394251}.event-row{display:grid;grid-template-columns:51px 77px 1fr;gap:12px;align-items:start;font-size:14px;line-height:1.5;padding:15px 20px;border-bottom:1px solid #202d3c}.event-row:last-child{border:0}.event-time{font:12px monospace;color:#788da6;padding-top:4px}.event-kind{font-size:11px;line-height:24px;color:#98b9ef;text-transform:capitalize}.event-kind.error{color:#fb8994}.event-kind.memory{color:#b8a5e8}.event-kind.lifecycle{color:#7ec9aa}.bot-name{display:flex;align-items:center;gap:12px}.avatar{width:37px;height:40px;border:1px solid #526084;border-radius:7px;display:grid;place-items:center;font:20px Georgia,serif;color:#c7b7ee;background:linear-gradient(140deg,#333555,#19263b)}.avatar.Warrior,.avatar.Paladin{color:#e3bb70;border-color:#786040;background:linear-gradient(140deg,#453a2b,#282b32)}.avatar.Rogue{color:#88cba9;border-color:#3b715e}.bot-name strong{font-size:14px}.bot-name small{display:block;margin-top:4px;font-size:12px}.data-table th{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#8297b1;padding:14px 20px}.data-table td{padding:15px 20px;font-size:14px}.data-table tbody tr{cursor:pointer}.data-table tbody tr:hover{background:#192738}.recipe-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:18px}.recipe-card{padding:22px;cursor:pointer;text-align:left;width:100%;transition:border-color .2s}.recipe-card:hover{border-color:#7093c5}.recipe-card h3{margin:14px 0 10px}.recipe-card p{color:var(--muted);font-size:14px;line-height:1.6;min-height:46px}.recipe-card .tags{margin-top:18px;display:flex;gap:6px;flex-wrap:wrap}.recipe-symbol{width:40px;height:40px;border:1px solid #4b5674;background:#202c40;display:grid;place-items:center;border-radius:8px;color:#b5c9eb}.section-grid{display:grid;grid-template-columns:1fr 1fr;gap:22px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:17px}.field{display:flex;flex-direction:column;gap:8px;font-size:14px}.field.full{grid-column:1/-1}.field input,.field textarea{background:#0b1420;border:1px solid #35465c;border-radius:5px;padding:10px;width:100%;color:#e1e9f4}.field textarea{min-height:84px;resize:vertical}.field [data-slot=select-trigger]{width:100%;height:42px;background:#0b1420}.form-footer{display:flex;justify-content:flex-end;gap:10px;margin-top:23px}.range-label{display:flex;justify-content:space-between;font-size:14px;margin-bottom:13px}.range-row{margin:18px 0}.choice-row{display:flex;align-items:center;gap:10px;font-size:14px}.choices{display:grid;grid-template-columns:1fr 1fr;gap:14px}.detail-header{padding:24px 0;display:flex;gap:16px;align-items:center}.detail-header .avatar{width:57px;height:63px;font-size:30px}.detail-header h2{font:27px Georgia,serif}.detail-block{padding:20px 0;border-bottom:1px solid var(--border)}.detail-block h3{margin-bottom:13px}.detail-block p{font-size:14px;color:#b1bfd0;line-height:1.7}.memory{padding:13px;background:#0c1622;border-left:2px solid #8c79b5;margin:12px 0}.memory small{display:block;margin-top:7px}.list-muted{color:var(--muted);font-size:14px;line-height:1.7}.zone-row{display:flex;align-items:center;gap:15px;font-size:14px;margin-bottom:20px}.zone-row .bar{flex:1;height:5px;background:#243449;border-radius:5px}.zone-row .bar i{display:block;height:100%;background:#759acc;border-radius:5px}.zone-row .zone-label{width:116px}.engine-box{display:flex;gap:14px;padding:18px;border:1px solid var(--border);border-radius:6px;margin-bottom:15px}.engine-box svg{color:#d2af73}.engine-box p{font-size:14px;color:var(--muted);line-height:1.6;margin-top:6px}.notice{padding:15px 18px;background:#1e2939;border:1px solid #3b506a;border-radius:6px;font-size:14px;line-height:1.6;color:#b7c8df;margin-bottom:20px}.empty-note{padding:32px;text-align:center;color:var(--muted)}.toolbar{display:flex;gap:12px;align-items:center;margin-bottom:20px}.toolbar input{max-width:330px}.health-row{display:flex;justify-content:space-between;padding:12px 0;font-size:14px;border-bottom:1px solid var(--border)}.health-row span{color:var(--muted)}.status-message{margin-top:15px;color:#b7c8df;font-size:14px}.fineprint{font-size:12px;line-height:1.7;color:#8296b0;margin-top:16px}.mobile-trigger{display:none}.screen-loading{padding:40px}.mobile-zone{display:none}@media(max-width:1100px){.content{padding:24px}.dashboard-grid{grid-template-columns:1fr}.stats{gap:10px}.stat{padding:16px}.section-grid{grid-template-columns:1fr}.director-strip{align-items:flex-start}.topbar{padding:0 24px}}@media(max-width:767px){.mobile-trigger{display:inline-flex}.content{padding:20px 15px}.topbar{padding:0 15px}.top-right>span:last-child{display:none}.page-heading{align-items:flex-start;flex-direction:column;gap:15px}h1{font-size:29px}.stats{grid-template-columns:1fr 1fr}.stat-value{font-size:27px}.director-strip{flex-direction:column;padding:18px}.director-icon{display:none}.recipe-grid,.form-grid{grid-template-columns:1fr}.field.full{grid-column:auto}.event-row{grid-template-columns:42px 1fr;gap:8px}.event-kind{display:none}.recipe-card p{min-height:0}.topbar .crumb{font-size:12px}.panel-head{padding:16px}.panel-body{padding:17px}} .gm-preview{font-family:monospace;font-size:13px;line-height:1.8;margin:20px 0;padding:16px;background:#080e17;border-left:2px solid #688ee3}.gm-preview span{color:#79a9ff}.gm-preview code{color:#b7bfce}.console-output{min-height:390px;max-height:570px;overflow:auto;background:#060c14;padding:25px;font:14px/1.7 monospace}.console-welcome{color:#79a7f5;letter-spacing:1px;margin-bottom:20px}.console-welcome span{color:#a0adc1;font-size:12px;letter-spacing:0}.console-muted{color:#788ba6;margin-bottom:20px}.console-entry{border-top:1px solid #1c2737;padding:14px 0;color:#9ccef2}.console-entry pre{white-space:pre-wrap;overflow-wrap:anywhere;font:13px/1.7 monospace;margin-top:8px}.console-error{color:#ec8e9c}.console-input{display:flex;gap:10px;align-items:center;padding:16px;background:#101b29}.console-input>span{color:#83adf8;font:20px monospace}.console-input input{font-family:monospace}.gm-shortcut{display:flex;justify-content:space-between;gap:15px;border:1px solid #2b3a4e;border-radius:5px;padding:12px;width:100%;margin-bottom:10px;font-size:14px;text-align:left}.gm-shortcut:hover{background:#203047}.gm-shortcut code{font-size:12px;color:#8eafe4} *,::before,::after{border-color:var(--border)} + +.readonly-fieldset{border:0;padding:0;margin:0;min-width:0}fieldset[disabled] button,fieldset[disabled] input,fieldset[disabled] textarea{cursor:default} diff --git a/app/page.tsx b/app/page.tsx index 90d8ad8..3704a6e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,4 +1,5 @@ 'use client'; +import {CommunityPanel,OperationsPanel} from './community-panel'; import { useState,useEffect,useCallback } from 'react'; import { LayoutDashboard,Users,BookOpen,Compass,Server,Activity,Brain,Sparkles,Plus,Play,Pause,ArrowRight,Shield,Terminal,ScrollText,Radio,RefreshCw,Heart,MapPin,Network,Copy,Send,House,Globe2,Moon,Sun,Skull,Clock3 } from 'lucide-react'; import { SidebarProvider,Sidebar,SidebarHeader,SidebarContent,SidebarMenu,SidebarMenuItem,SidebarMenuButton,SidebarFooter,SidebarTrigger } from '@/components/ui/sidebar'; @@ -16,52 +17,55 @@ import { Table,TableHeader,TableRow,TableHead,TableBody,TableCell } from '@/comp import { Skeleton } from '@/components/ui/skeleton'; import { Toaster,toast } from 'sonner'; import type { State,Bot,Recipe,RealmEvent,Activity as BotActivity } from '@/core/types'; -const NAV=[['Dashboard',LayoutDashboard],['World Monitor',Globe2],['Realm Director',Compass],['World & Housing',House],['Bots',Users],['Bot Recipes',BookOpen],['Memory & Bonds',Heart],['Event Stream',Activity],['GM Console',Terminal],['Server & Models',Server]] as const; +const NAV=[['Dashboard',LayoutDashboard],['World Monitor',Globe2],['Social Life',Heart],['System Health',Activity],['Realm Director',Compass],['World & Housing',House],['Bots',Users],['Bot Recipes',BookOpen],['Memory & Bonds',Heart],['Event Stream',Activity],['GM Console',Terminal],['Server & Models',Server]] as const; const ACTIVITIES:BotActivity[]=['explore','quest','socialize','trade','rest']; function Choice({value,onChange,items,label}:{value:string;onChange:(v:string)=>void;items:{value:string;label:string}[];label:string}){return } const options=(items:string[])=>items.map(value=>({value,label:value})); function Avatar({bot}:{bot:Bot}){return } function Panel({title,icon:Icon,children,action,className=''}:any){return

{Icon&&}{title}

{action}
{children}
} function Events({events}:{events:RealmEvent[]}){return
{events.length?[...events].reverse().map(e=>
{new Date(e.at).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}{e.kind}{e.message}
):
No events match this filter.
}
} -export default function Home(){ +export default function Home({readOnly=false}:{readOnly?:boolean}){ const [state,setState]=useState(null),[page,setPage]=useState('Dashboard'),[error,setError]=useState(''),[busy,setBusy]=useState(false),[query,setQuery]=useState(''),[filter,setFilter]=useState('all'),[botId,setBotId]=useState(null),[createOpen,setCreateOpen]=useState(false),[recipe,setRecipe]=useState(null),[recipeOpen,setRecipeOpen]=useState(false),[confirm,setConfirm]=useState<{title:string;description:string;action:any}|null>(null),[capabilities,setCapabilities]=useState({}),[monitor,setMonitor]=useState(null); const refresh=useCallback(async()=>{try{const r=await fetch('/api/state');const d:any=await r.json();if(!r.ok)throw new Error(d.error);setState(d);setError('');}catch(e){setError(e instanceof Error?e.message:'Could not load realm');}},[]); useEffect(()=>{void refresh();void fetch('/api/health').then(r=>r.json()).then(setCapabilities).catch(()=>{});const timer=setInterval(refresh,5000);return()=>clearInterval(timer);},[refresh]); useEffect(()=>{if(page!=='World Monitor')return;let cancelled=false;const load=async()=>{try{const r=await fetch('/api/world-monitor');const d=await r.json();if(!cancelled)setMonitor(d);}catch{if(!cancelled)setMonitor(null);}};void load();const timer=setInterval(load,3000);return()=>{cancelled=true;clearInterval(timer);};},[page]); - const act=useCallback(async(action:any)=>{setBusy(true);try{const r=await fetch('/api/actions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(action)});const d:any=await r.json();if(!r.ok)throw new Error(d.error);await refresh();return d;}catch(e){toast.error(e instanceof Error?e.message:'Action failed');await refresh();throw e;}finally{setBusy(false);}},[refresh]); + const act=useCallback(async(action:any)=>{if(readOnly)throw new Error('Visitor access is read-only');setBusy(true);try{const r=await fetch('/api/actions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(action)});const d:any=await r.json();if(!r.ok)throw new Error(d.error);await refresh();return d;}catch(e){toast.error(e instanceof Error?e.message:'Action failed');await refresh();throw e;}finally{setBusy(false);}},[refresh,readOnly]); const perform=(action:any)=>{void act(action).catch(()=>{});}; - useEffect(()=>{const context=(document as any).modelContext;if(!context?.registerTool)return;const controller=new AbortController();const register=(tool:any)=>{try{Promise.resolve(context.registerTool(tool,{signal:controller.signal})).catch(()=>{});}catch{}}; + useEffect(()=>{if(readOnly)return;const context=(document as any).modelContext;if(!context?.registerTool)return;const controller=new AbortController();const register=(tool:any)=>{try{Promise.resolve(context.registerTool(tool,{signal:controller.signal})).catch(()=>{});}catch{}}; register({name:'azerothai_read_realm',description:'Read current bot identities, recipes, director settings and recent events.',inputSchema:{type:'object',properties:{},additionalProperties:false},annotations:{readOnlyHint:true},execute:async()=>{const r=await fetch('/api/state');if(!r.ok)throw new Error('Realm unavailable');return r.json();}}); - register({name:'azerothai_create_bot',description:'Create an offline bot identity; does not spawn it in game.',inputSchema:{type:'object',properties:{name:{type:'string'},role:{type:'string'},race:{type:'string'},recipeId:{type:'string'},goal:{type:'string'}},required:['name','role','race','recipeId','goal'],additionalProperties:false},annotations:{readOnlyHint:false},execute:async(input:any)=>act({...input,type:'createBot'})});return()=>controller.abort();},[act]); + register({name:'azerothai_create_bot',description:'Create an offline bot identity; does not spawn it in game.',inputSchema:{type:'object',properties:{name:{type:'string'},role:{type:'string'},race:{type:'string'},recipeId:{type:'string'},goal:{type:'string'}},required:['name','role','race','recipeId','goal'],additionalProperties:false},annotations:{readOnlyHint:false},execute:async(input:any)=>act({...input,type:'createBot'})});return()=>controller.abort();},[act,readOnly]); function nav(p:string){setPage(p);setQuery('');setFilter('all');} const selected=state?.bots.find(b=>b.id===botId); const visible=state?.bots.filter(b=>(`${b.name} ${b.role} ${b.zone}`.toLowerCase().includes(query.toLowerCase()))&&(filter==='all'||b.status===filter))??[]; function botTable(bots:Bot[]){return CharacterStatusLocationRecipe{bots.map(b=>setBotId(b.id)}>
Lv. {b.level} · {b.race} {b.role}
{b.status}{b.zone}{b.personality.name.replace('The ','')}
)}
} - return
A
AzerothAIYOUR WORLD. ALIVE.
REALM MANAGEMENT
{NAV.map(([name,Icon])=>nav(name)}>{name})}

Realm administrator

Prototype 0.1 · Private realm

“What if Azeroth never felt empty?”

Living Realm / {page}
{capabilities.aleActiveRecently?'AZEROTHCORE LIVE':state?.settings.connector==='azerothcore'?'AzerothCore bridge':'SIMULATION REALM'}GM workspace
-
{page==='GM Console'?'THE WORLD IS YOUR SANDBOX':'AZEROTHAI CONTROL PLANE'}

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

{({'Dashboard':'The people, personalities, and stories that make your world.','World Monitor':'Watch Azeroth breathe: population, activity, systems, and the lives unfolding right now.','Realm Director':'Shape the adventure around you. Let your characters bring it to life.','World & Housing':'Homes, routines, simulation bands, and believable population flow.','Bots':'Give every character a purpose, then let their story unfold.','Bot Recipes':'Same model. Different personalities. A world of possibilities.','Memory & Bonds':'A shared history makes the next encounter matter.','Event Stream':'Follow the decisions and changes happening across your realm.','GM Console':'Pull up a chair. Put on the blue robe. Run your world.','Server & Models':'Connect the game engine and the minds behind your characters.'} as any)[page]}

{page==='Bot Recipes'?:<>}
+ return
A
AzerothAIYOUR WORLD. ALIVE.
REALM MANAGEMENT
{NAV.map(([name,Icon])=>nav(name)}>{name})}

{readOnly?'Realm visitor · Read only':'Realm administrator'}

{readOnly?'Live realm monitor':'Prototype 0.1 · Private realm'}

“What if Azeroth never felt empty?”

Living Realm / {page}
{capabilities.aleActiveRecently?'AZEROTHCORE LIVE':state?.settings.connector==='azerothcore'?'AzerothCore bridge':'SIMULATION REALM'}{readOnly?'Visitor · Read only':'GM workspace'}
+
{page==='GM Console'?'THE WORLD IS YOUR SANDBOX':'AZEROTHAI CONTROL PLANE'}

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

{({'Dashboard':'The people, personalities, and stories that make your world.','World Monitor':'Watch Azeroth breathe: population, activity, systems, and the lives unfolding right now.','Realm Director':'Shape the adventure around you. Let your characters bring it to life.','World & Housing':'Homes, routines, simulation bands, and believable population flow.','Bots':'Give every character a purpose, then let their story unfold.','Bot Recipes':'Same model. Different personalities. A world of possibilities.','Memory & Bonds':'A shared history makes the next encounter matter.','Event Stream':'Follow the decisions and changes happening across your realm.','GM Console':'Pull up a chair. Put on the blue robe. Run your world.','Server & Models':'Connect the game engine and the minds behind your characters.'} as any)[page]}

{readOnly?:page==='Bot Recipes'?:<>}
{error&&
{error}
} {!state?
{[1,2,3,4].map(n=>)}
:<> - {page==='Dashboard'&&<>
{[[Users,'Active bots',state.bots.filter(b=>b.status==='online').length,'Online in the simulation'],[BookOpen,'Bot recipes',state.recipes.length,'Distinct ways to experience the world'],[Brain,'Memories',state.bots.reduce((n,b)=>n+b.memories.length,0),'Stories carried into future decisions'],[Network,'Relationships',state.bots.reduce((n,b)=>n+b.relationships.length,0),'Directed bonds between characters']].map(([Icon,label,value,note]:any)=>
{label}
{value}
{label==='Active bots'&&state.settings.connector!=='simulation'?'Acknowledged by the game bridge':note}
)}
YOUR NEXT ADVENTURE

{state.director.experience}

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

nav('Bots')}>Manage bots }>{botTable(state.bots.slice(0,5))}
{Array.from(new Set(state.bots.filter(b=>b.status==='online').map(b=>b.zone))).map(zone=>{const n=state.bots.filter(b=>b.status==='online'&&b.zone===zone).length;return
{zone}b.status==='online').length)*100}%`}}/>{n}
})}

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

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

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

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

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

<GM> AzerothAI

Welcome to your world.

.announce An adventure awaits...
} + {page==='Dashboard'&&<>
{[[Users,'Active bots',state.bots.filter(b=>b.status==='online'&&(state.settings.connector==='simulation'||b.autonomy?.managed)).length,'Online in the simulation'],[BookOpen,'Bot recipes',state.recipes.length,'Distinct ways to experience the world'],[Brain,'Memories',state.bots.reduce((n,b)=>n+b.memories.length,0),'Stories carried into future decisions'],[Network,'Relationships',state.bots.reduce((n,b)=>n+b.relationships.length,0),'Directed bonds between characters']].map(([Icon,label,value,note]:any)=>
{label}
{value}
{label==='Active bots'&&state.settings.connector!=='simulation'?'Acknowledged by the game bridge':note}
)}
YOUR NEXT ADVENTURE

{state.director.experience}

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

nav('Bots')}>{readOnly?'View bots':'Manage bots'} }>{botTable(state.bots.slice(0,5))}
{Array.from(new Set(state.bots.filter(b=>b.status==='online').map(b=>b.zone))).map(zone=>{const n=state.bots.filter(b=>b.status==='online'&&b.zone===zone).length;return
{zone}b.status==='online').length)*100}%`}}/>{n}
})}

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

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

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

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

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

<GM> AzerothAI

Welcome to your world.

.announce An adventure awaits...
} {page==='World Monitor'&&} {page==='Bots'&&<>
setQuery(e.target.value)}/>{visible.length} characters
{visible.length?botTable(visible):
No matching bots. Create a character or adjust your filters.
}
} - {page==='Bot Recipes'&&<>
Recipes are versioned. Saving a recipe preserves existing bots’ personalities until you explicitly apply the new version.
{state.recipes.map((r,i)=>)}
} - {page==='Realm Director'&&} + {page==='Bot Recipes'&&<>
Recipes are versioned. Saving a recipe preserves existing bots’ personalities until you explicitly apply the new version.
{state.recipes.map((r,i)=>)}
} + {page==='Realm Director'&&
} + {page==='World & Housing'&&
} {page==='Memory & Bonds'&&
{state.bots.map(b=>setBotId(b.id)}>Open profile}>

{b.memories.length} memories · {b.relationships.length} relationships

{b.memories.at(-1)?.text??'No memories yet.'}

{b.relationships.map(r=>

{state.bots.find(x=>x.id===r.targetId)?.name} · {r.affinity>0?'+':''}{r.affinity} affinity
{r.note}

)}
)}
} - {page==='Event Stream'&&} - {page==='Server & Models'&&} - {page==='GM Console'&&} + {page==='Social Life'&&} + {page==='System Health'&&} + {page==='Event Stream'&&} + {page==='Server & Models'&&
} + {page==='GM Console'&&
} }

AzerothAI shapes identity and intent. AzerothCore owns the rules of the world.

Create a characterStart with an identity and recipe. Spawn when you’re ready.{state&&setCreateOpen(false)}/>} - {recipe?'Edit bot recipe':'Create bot recipe'}Traits guide high-level decisions. They never override game rules.setRecipeOpen(false)}/> - {if(!o)setBotId(null)}}>Bot profileIdentity, intent, and the story so far.{selected&&state&&} + {readOnly?'Bot recipe':recipe?'Edit bot recipe':'Create bot recipe'}Traits guide high-level decisions. They never override game rules.
setRecipeOpen(false)}/>
+ {if(!o)setBotId(null)}}>Bot profileIdentity, intent, and the story so far.{selected&&state&&} {if(!o)setConfirm(null)}}>{confirm?.title}{confirm?.description}Cancel{if(confirm)perform(confirm.action);setConfirm(null)}}>Confirm
; } function CreateBot({state,act,busy,close}:any){const [name,setName]=useState(''),[role,setRole]=useState('Priest'),[race,setRace]=useState('Human'),[recipeId,setRecipeId]=useState(state.recipes[0].id),[goal,setGoal]=useState(`Support ${state.director.experience.toLowerCase()} in ${state.director.zone}`);return
{e.preventDefault();try{await act({type:'createBot',name,role,race,recipeId,goal});toast.success('Character created');close();}catch{}}}>