Files
AzerothAI/app/page.tsx
T
2026-09-22 20:46:02 -04:00

150 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import {RealmInsights} from './realm-insights';
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';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog,DialogContent,DialogHeader,DialogTitle,DialogDescription } from '@/components/ui/dialog';
import { Sheet,SheetContent,SheetHeader,SheetTitle,SheetDescription } from '@/components/ui/sheet';
import { AlertDialog,AlertDialogContent,AlertDialogHeader,AlertDialogTitle,AlertDialogDescription,AlertDialogFooter,AlertDialogCancel,AlertDialogAction } from '@/components/ui/alert-dialog';
import { Tabs,TabsList,TabsTrigger,TabsContent } from '@/components/ui/tabs';
import { Select,SelectTrigger,SelectValue,SelectContent,SelectItem } from '@/components/ui/select';
import { Slider } from '@/components/ui/slider';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import { Table,TableHeader,TableRow,TableHead,TableBody,TableCell } from '@/components/ui/table';
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],['Nightly Report',ScrollText],['Bot Activity',Clock3],['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 <Select value={value} onValueChange={onChange}><SelectTrigger aria-label={label}><SelectValue/></SelectTrigger><SelectContent>{items.map(i=><SelectItem key={i.value} value={i.value}>{i.label}</SelectItem>)}</SelectContent></Select>}
const options=(items:string[])=>items.map(value=>({value,label:value}));
function Avatar({bot}:{bot:Bot}){return <div className={`avatar ${bot.role}`} aria-hidden="true">{bot.name.slice(0,1)}</div>}
function Panel({title,icon:Icon,children,action,className=''}:any){return <section className={`panel ${className}`}><div className="panel-head"><h2>{Icon&&<Icon/>}{title}</h2>{action}</div>{children}</section>}
function Events({events}:{events:RealmEvent[]}){return <div>{events.length?[...events].reverse().map(e=><div className="event-row" key={e.id}><span className="event-time">{new Date(e.at).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</span><span className={`event-kind ${e.kind}`}>{e.kind}</span><span>{e.message}</span></div>):<div className="empty-note">No events match this filter.</div>}</div>}
export default function Home({readOnly=false}:{readOnly?:boolean}){
const [state,setState]=useState<State|null>(null),[page,setPage]=useState('Dashboard'),[error,setError]=useState(''),[busy,setBusy]=useState(false),[query,setQuery]=useState(''),[filter,setFilter]=useState('all'),[botId,setBotId]=useState<string|null>(null),[createOpen,setCreateOpen]=useState(false),[recipe,setRecipe]=useState<Recipe|null>(null),[recipeOpen,setRecipeOpen]=useState(false),[confirm,setConfirm]=useState<{title:string;description:string;action:any}|null>(null),[capabilities,setCapabilities]=useState<any>({}),[monitor,setMonitor]=useState<any>(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)=>{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(()=>{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,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 <Table className="data-table"><TableHeader><TableRow><TableHead>Character</TableHead><TableHead>Status</TableHead><TableHead>Location</TableHead><TableHead>Recipe</TableHead><TableHead/></TableRow></TableHeader><TableBody>{bots.map(b=><TableRow key={b.id} onClick={()=>setBotId(b.id)}><TableCell><div className="bot-name"><Avatar bot={b}/><div><button onClick={()=>setBotId(b.id)}><strong>{b.name}</strong></button><small>Lv. {b.level} · {b.race} {b.role}</small></div></div></TableCell><TableCell><span className={`tag ${b.status}`}>{b.status}</span></TableCell><TableCell>{b.zone}</TableCell><TableCell><small>{b.personality.name.replace('The ','')}</small></TableCell><TableCell><ArrowRight size={15}/></TableCell></TableRow>)}</TableBody></Table>}
return <SidebarProvider style={{'--sidebar-width':'228px'} as React.CSSProperties}><Sidebar><SidebarHeader className="p-0"><div className="brand"><span className="brand-mark">A</span><div><strong>AzerothAI</strong><small>YOUR WORLD. ALIVE.</small></div></div></SidebarHeader><SidebarContent className="px-3"><div className="nav-section">REALM MANAGEMENT</div><SidebarMenu>{NAV.map(([name,Icon])=><SidebarMenuItem key={name}><SidebarMenuButton className="nav-button" isActive={page===name} onClick={()=>nav(name)}><Icon/><span>{name}</span></SidebarMenuButton></SidebarMenuItem>)}</SidebarMenu></SidebarContent><SidebarFooter><div className="sidebar-bottom"><Shield size={20} color="#d9b77a"/><p style={{marginTop:10,color:'#d6dfec'}}>{readOnly?'Realm visitor · Read only':'Realm administrator'}</p><small>{readOnly?'Live realm monitor':'Prototype 0.1 · Private realm'}</small><p style={{marginTop:20,fontFamily:'Georgia',fontStyle:'italic'}}>“What if Azeroth never felt empty?”</p></div></SidebarFooter></Sidebar><main className="app-main"><header className="topbar"><div className="crumb"><SidebarTrigger className="mobile-trigger"/>Living Realm <span>/</span> <span style={{color:'#d8e2f0'}}>{page}</span></div><div className="top-right"><span className="sim-badge">{capabilities.aleActiveRecently?'AZEROTHCORE LIVE':state?.settings.connector==='azerothcore'?'AzerothCore bridge':'SIMULATION REALM'}</span><span>{readOnly?'Visitor · Read only':'GM workspace'}</span></div></header><div className="content">
<div className="page-heading"><div><div className="eyebrow">{page==='GM Console'?'THE WORLD IS YOUR SANDBOX':'AZEROTHAI CONTROL PLANE'}</div><h1>{page==='Dashboard'?'Your realm, alive.':page}</h1><p className="subtitle">{({'Dashboard':'The people, personalities, and stories that make your world.','World Monitor':'Watch Azeroth breathe: population, activity, systems, and the lives unfolding right now.','Realm Director':'Shape the adventure around you. Let your characters bring it to life.','World & Housing':'Homes, routines, simulation bands, and believable population flow.','Bots':'Give every character a purpose, then let their story unfold.','Bot Recipes':'Same model. Different personalities. A world of possibilities.','Memory & Bonds':'A shared history makes the next encounter matter.','Event Stream':'Follow the decisions and changes happening across your realm.','GM Console':'Pull up a chair. Put on the blue robe. Run your world.','Server & Models':'Connect the game engine and the minds behind your characters.'} as any)[page]}</p></div><div className="actions">{readOnly?<Button variant="outline" onClick={()=>void refresh()}><RefreshCw size={15}/>Refresh</Button>:page==='Bot Recipes'?<Button onClick={()=>{setRecipe(null);setRecipeOpen(true)}}><Plus size={16}/>Create recipe</Button>:<><Button variant="outline" disabled={busy} onClick={()=>void refresh()}><RefreshCw size={15}/>Refresh</Button><Button onClick={()=>setCreateOpen(true)}><Plus size={16}/>Create bot</Button></>}</div></div>
{error&&<div className="notice" role="alert">{error} <Button variant="outline" onClick={()=>void refresh()}>Retry</Button></div>}
{!state?<div className="stats">{[1,2,3,4].map(n=><Skeleton key={n} className="h-32"/>)}</div>:<>
{(page==='Nightly Report'||page==='Bot Activity')&&<RealmInsights mode={page}/>}
{page==='Dashboard'&&<><div className="stats">{[[Users,'Active bots',state.bots.filter(b=>b.status==='online'&&(state.settings.connector==='simulation'||b.autonomy?.managed)).length,'Online in the simulation'],[BookOpen,'Bot recipes',state.recipes.length,'Distinct ways to experience the world'],[Brain,'Memories',state.bots.reduce((n,b)=>n+b.memories.length,0),'Stories carried into future decisions'],[Network,'Relationships',state.bots.reduce((n,b)=>n+b.relationships.length,0),'Directed bonds between characters']].map(([Icon,label,value,note]:any)=><div className="panel stat" key={label}><div className="stat-label">{label}<Icon/></div><div className="stat-value">{value}</div><small>{label==='Active bots'&&state.settings.connector!=='simulation'?'Acknowledged by the game bridge':note}</small></div>)}</div><section className="panel director-strip"><div className="director-content"><Compass className="director-icon"/><div><div className="eyebrow">YOUR NEXT ADVENTURE</div><h2>{state.director.experience}</h2><p>{state.director.zone} · {state.director.brief}</p></div></div><Button variant="outline" onClick={()=>nav('Realm Director')}>{readOnly?'View realm direction':'Shape the experience'} <ArrowRight size={15}/></Button></section><div className="dashboard-grid"><Panel title="Your characters" icon={Users} action={<Button variant="ghost" size="sm" onClick={()=>nav('Bots')}>{readOnly?'View bots':'Manage bots'} <ArrowRight size={14}/></Button>}>{botTable(state.bots.slice(0,5))}</Panel><Panel title="Realm pulse" icon={Radio}><div className="panel-body">{Array.from(new Set(state.bots.filter(b=>b.status==='online').map(b=>b.zone))).map(zone=>{const n=state.bots.filter(b=>b.status==='online'&&b.zone===zone).length;return <div className="zone-row" key={zone}><span className="zone-label">{zone}</span><span className="bar"><i style={{width:`${n/Math.max(1,state.bots.filter(b=>b.status==='online').length)*100}%`}}/></span><span>{n}</span></div>})}<p className="fineprint">Bot locations from the selected connector. Simulation locations are illustrative.</p><div className="engine-box" style={{marginTop:25,marginBottom:0}}><Brain size={23}/><div><h3>{state.settings.provider==='deterministic'?'Personality simulator':state.settings.model}</h3><p>{state.settings.provider==='deterministic'?'No model required. Try a decision from any online bot.':'Local model selected. Each decision includes identity and memories.'}</p></div></div></div></Panel><Panel title="Recent activity" icon={Activity} action={<Button variant="ghost" size="sm" onClick={()=>nav('Event Stream')}>View stream <ArrowRight size={14}/></Button>}>{<Events events={state.events.slice(-5)}/>}</Panel><Panel title="The Game Master’s chair" icon={Shield}><div className="panel-body"><p className="list-muted">Set the scene. Announce a gathering. Move a character to the next chapter of their story.</p><div className="gm-preview"><span>&lt;GM&gt; AzerothAI</span><p>Welcome to your world.</p><code>.announce An adventure awaits...</code></div><Button variant="outline" onClick={()=>nav('GM Console')}>Open GM console <Terminal size={15}/></Button></div></Panel></div></>}
{page==='World Monitor'&&<WorldMonitor state={state} monitor={monitor} capabilities={capabilities} openBot={setBotId}/>}
{page==='Bots'&&<><div className="toolbar"><Input aria-label="Search bots" placeholder="Search name, class, or location…" value={query} onChange={e=>setQuery(e.target.value)}/><Choice label="Bot status" value={filter} onChange={setFilter} items={options(['all','online','paused','offline','retired'])}/><small>{visible.length} characters</small></div><Panel title="Bot lifecycle" icon={Users}>{visible.length?botTable(visible):<div className="empty-note">No matching bots. Create a character or adjust your filters.</div>}</Panel></>}
{page==='Bot Recipes'&&<><div className="notice">Recipes are versioned. Saving a recipe preserves existing bots’ personalities until you explicitly apply the new version.</div><div className="recipe-grid">{state.recipes.map((r,i)=><button key={r.id} className="panel recipe-card" onClick={()=>{setRecipe(r);setRecipeOpen(true)}}><div style={{display:'flex',justifyContent:'space-between'}}><span className="recipe-symbol">{i%2?<Compass size={23}/>:<BookOpen size={23}/>}</span><small>VERSION {r.version}</small></div><h3>{r.name}</h3><p>{r.description}</p><div className="tags">{r.tags.map(t=><span className="tag" key={t}>{t}</span>)}</div><div style={{display:'flex',justifyContent:'space-between',marginTop:21}}><small>{state.bots.filter(b=>b.recipeId===r.id).length} bot identities</small><small style={{color:'#9abef7'}}>{readOnly?'View recipe →':'Edit recipe →'}</small></div></button>)}</div></>}
{page==='Realm Director'&&<fieldset disabled={readOnly} className="readonly-fieldset"><Director state={state} act={act} busy={busy||readOnly}/></fieldset>}
{page==='World & Housing'&&<fieldset disabled={readOnly} className="readonly-fieldset"><WorldHousing state={state} act={act} busy={busy||readOnly}/></fieldset>}
{page==='Memory & Bonds'&&<div className="recipe-grid">{state.bots.map(b=><Panel key={b.id} title={b.name} icon={Heart} action={<Button variant="ghost" size="sm" onClick={()=>setBotId(b.id)}>Open profile</Button>}><div className="panel-body"><p className="list-muted">{b.memories.length} memories · {b.relationships.length} relationships</p><div className="memory"><p>{b.memories.at(-1)?.text??'No memories yet.'}</p></div>{b.relationships.map(r=><p className="list-muted" key={r.targetId}>{state.bots.find(x=>x.id===r.targetId)?.name} · {r.affinity>0?'+':''}{r.affinity} affinity<br/>{r.note}</p>)}</div></Panel>)}</div>}
{page==='Social Life'&&<CommunityPanel state={state} act={act} busy={busy||readOnly}/>}
{page==='System Health'&&<OperationsPanel state={state} act={act} busy={busy||readOnly}/>}
{page==='Event Stream'&&<EventStream state={state} readOnly={readOnly}/>}
{page==='Server & Models'&&<fieldset disabled={readOnly} className="readonly-fieldset"><Settings state={state} act={act} busy={busy||readOnly} capabilities={capabilities}/></fieldset>}
{page==='GM Console'&&<fieldset disabled={readOnly} className="readonly-fieldset"><GM state={state} act={act} busy={busy||readOnly} capabilities={capabilities} setConfirm={setConfirm}/></fieldset>}
</>}
<p className="fineprint" style={{textAlign:'center',marginTop:28}}>AzerothAI shapes identity and intent. AzerothCore owns the rules of the world.</p></div></main>
<Dialog open={createOpen} onOpenChange={setCreateOpen}><DialogContent className="sm:max-w-xl"><DialogHeader><DialogTitle>Create a character</DialogTitle><DialogDescription>Start with an identity and recipe. Spawn when you’re ready.</DialogDescription></DialogHeader>{state&&<CreateBot state={state} busy={busy} act={act} close={()=>setCreateOpen(false)}/>}</DialogContent></Dialog>
<Dialog open={recipeOpen} onOpenChange={setRecipeOpen}><DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto"><DialogHeader><DialogTitle>{readOnly?'Bot recipe':recipe?'Edit bot recipe':'Create bot recipe'}</DialogTitle><DialogDescription>Traits guide high-level decisions. They never override game rules.</DialogDescription></DialogHeader><fieldset disabled={readOnly} className="readonly-fieldset"><RecipeEditor key={recipe?.id??'new'} recipe={recipe} busy={busy} act={act} close={()=>setRecipeOpen(false)}/></fieldset></DialogContent></Dialog>
<Sheet open={!!selected} onOpenChange={o=>{if(!o)setBotId(null)}}><SheetContent className="w-full sm:max-w-xl overflow-y-auto p-6"><SheetHeader className="p-0"><SheetTitle>Bot profile</SheetTitle><SheetDescription>Identity, intent, and the story so far.</SheetDescription></SheetHeader>{selected&&state&&<BotProfile readOnly={readOnly} key={selected.id} bot={selected} state={state} busy={busy} act={act} perform={perform} confirm={setConfirm}/>}</SheetContent></Sheet>
<AlertDialog open={!!confirm} onOpenChange={o=>{if(!o)setConfirm(null)}}><AlertDialogContent><AlertDialogHeader><AlertDialogTitle>{confirm?.title}</AlertDialogTitle><AlertDialogDescription>{confirm?.description}</AlertDialogDescription></AlertDialogHeader><AlertDialogFooter><AlertDialogCancel>Cancel</AlertDialogCancel><AlertDialogAction onClick={()=>{if(confirm)perform(confirm.action);setConfirm(null)}}>Confirm</AlertDialogAction></AlertDialogFooter></AlertDialogContent></AlertDialog><Toaster theme="dark" richColors position="bottom-right"/></SidebarProvider>;
}
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 <form onSubmit={async e=>{e.preventDefault();try{await act({type:'createBot',name,role,race,recipeId,goal});toast.success('Character created');close();}catch{}}}><div className="form-grid"><label className="field">Character name<Input required minLength={2} maxLength={24} value={name} onChange={e=>setName(e.target.value)} placeholder="A name worth remembering"/></label><label className="field">Class<Choice label="Class" value={role} onChange={setRole} items={options(['Priest','Warrior','Mage','Rogue','Hunter','Paladin','Warlock','Druid','Shaman'])}/></label><label className="field">Race<Choice label="Race" value={race} onChange={setRace} items={options(['Human','Night Elf','Dwarf','Orc','Undead','Tauren','Gnome','Troll','Blood Elf','Draenei'])}/></label><label className="field">Bot recipe<Choice label="Bot recipe" value={recipeId} onChange={setRecipeId} items={state.recipes.map((r:Recipe)=>({value:r.id,label:r.name}))}/></label><label className="field full">Personal goal<textarea required maxLength={200} value={goal} onChange={e=>setGoal(e.target.value)}/></label></div><p className="fineprint">The live game bridge validates race/class combinations when spawning.</p><div className="form-footer"><Button disabled={busy} type="submit">Create identity</Button></div></form>}
function RecipeEditor({recipe,act,busy,close}:any){const [r,setR]=useState<Recipe>(recipe??{name:'',description:'',tags:['Curious'],sociability:50,aggression:20,curiosity:70,economy:30,activities:['explore','quest']});const [tags,setTags]=useState(r.tags.join(', '));return <form onSubmit={async e=>{e.preventDefault();try{await act({type:'saveRecipe',recipe:{...r,tags:tags.split(',').map(t=>t.trim()).filter(Boolean)}});toast.success('Recipe saved');close();}catch{}}}><div className="form-grid"><label className="field full">Recipe name<Input required minLength={2} maxLength={80} value={r.name} onChange={e=>setR({...r,name:e.target.value})}/></label><label className="field full">Personality description<textarea maxLength={500} value={r.description} onChange={e=>setR({...r,description:e.target.value})}/></label><label className="field full">Tags, separated by commas<Input value={tags} onChange={e=>setTags(e.target.value)}/></label></div><div className="section-grid" style={{gap:25,marginTop:20}}><div>{(['sociability','aggression','curiosity','economy'] as const).map(key=><div className="range-row" key={key}><div className="range-label"><span style={{textTransform:'capitalize'}}>{key}</span><span>{r[key]}</span></div><Slider aria-label={key} min={0} max={100} step={1} value={[r[key]]} onValueChange={v=>setR({...r,[key]:v[0]})}/></div>)}</div><div><h3 style={{margin:'15px 0'}}>Preferred activities</h3><div className="choices">{ACTIVITIES.map(a=><label className="choice-row" key={a}><Checkbox checked={r.activities.includes(a)} onCheckedChange={v=>setR({...r,activities:v?[...r.activities,a]:r.activities.filter(x=>x!==a)})}/>{a}</label>)}</div></div></div><div className="form-footer">{recipe&&<Button type="button" variant="outline" disabled={busy} onClick={async()=>{try{const {id,version,...copy}=r;void id;void version;await act({type:'saveRecipe',recipe:{...copy,name:`${copy.name} copy`.slice(0,80)}});close();}catch{}}}><Copy size={15}/>Duplicate</Button>}<Button disabled={busy||!r.activities.length}>Save recipe</Button></div></form>}
function BotProfile({bot:b,state,act,busy,perform,confirm,readOnly=false}:any){const [memory,setMemory]=useState(''),[importance,setImportance]=useState(7),[target,setTarget]=useState(state.bots.find((x:Bot)=>x.id!==b.id)?.id??''),[affinity,setAffinity]=useState(40),[note,setNote]=useState(''),[recipeId,setRecipeId]=useState(b.recipeId);const actions=b.status==='online'?['pause','despawn']:b.status==='paused'?['resume','despawn']:b.status==='offline'?['spawn','retire']:[];return <><div className="detail-header"><Avatar bot={b}/><div><h2>{b.name}</h2><p className="subtitle">Level {b.level} {b.race} {b.role}</p></div><span className={`tag ${b.status}`} style={{marginLeft:'auto'}}>{b.status}</span></div><fieldset disabled={readOnly} className="readonly-fieldset"><div className="actions">{actions.map(a=><Button key={a} size="sm" variant={a==='spawn'?'default':'outline'} disabled={busy} onClick={()=>a==='retire'?confirm({title:`Retire ${b.name}?`,description:'The identity and memories remain in your realm, but this bot cannot spawn again in this prototype.',action:{type:'lifecycle',botId:b.id,action:a}}):perform({type:'lifecycle',botId:b.id,action:a})}>{a==='pause'?<Pause size={14}/>:a==='spawn'||a==='resume'?<Play size={14}/>:null}{a.charAt(0).toUpperCase()+a.slice(1)}</Button>)}<Button size="sm" disabled={busy||b.status!=='online'} onClick={()=>perform({type:'think',botId:b.id})}><Brain size={15}/>{busy?'Working…':'Run decision'}</Button></div></fieldset><Tabs defaultValue="identity" className="mt-6"><TabsList className="w-full"><TabsTrigger value="identity">Identity</TabsTrigger><TabsTrigger value="memory">Memory</TabsTrigger><TabsTrigger value="bonds">Relationships</TabsTrigger></TabsList><TabsContent value="identity"><div className="detail-block"><h3>Current goal</h3><p>{b.goal}</p><p style={{marginTop:12,display:'flex',gap:7,alignItems:'center'}}><MapPin size={15}/>{b.zone}</p></div><div className="detail-block"><h3>{b.personality.name} <small>v{b.recipeVersion}</small></h3><p>{b.personality.description}</p><div className="actions" style={{marginTop:12}}>{b.personality.tags.map((t:string)=><span className="tag" key={t}>{t}</span>)}</div><fieldset disabled={readOnly} className="field" style={{marginTop:20}}><Choice label="Apply recipe" value={recipeId} onChange={setRecipeId} items={state.recipes.map((r:Recipe)=>({value:r.id,label:`${r.name} · v${r.version}`}))}/><Button variant="outline" disabled={busy} onClick={()=>perform({type:'applyRecipe',botId:b.id,recipeId})}>Apply current recipe version</Button></fieldset></div><div className="detail-block"><h3>Latest decision</h3><p>{b.lastDecision?`${b.lastDecision.action}: ${b.lastDecision.reason}`:'No decisions yet. Run a decision while this bot is online.'}</p><small>{b.decisions} decisions acknowledged</small></div></TabsContent><TabsContent value="memory"><fieldset disabled={readOnly} className="readonly-fieldset"><form className="detail-block" onSubmit={async e=>{e.preventDefault();try{await act({type:'memory',botId:b.id,text:memory,importance});setMemory('');}catch{}}}><label className="field">Add a memory<textarea required value={memory} maxLength={1000} onChange={e=>setMemory(e.target.value)} placeholder="What should this character remember?"/></label><div className="range-row"><div className="range-label">Importance <span>{importance}/10</span></div><Slider aria-label="Memory importance" min={1} max={10} value={[importance]} onValueChange={v=>setImportance(v[0])}/></div><Button disabled={busy}>Save memory</Button></form></fieldset>{[...b.memories].reverse().map((m:any)=><div className="memory" key={m.id}><p className="list-muted">{m.text}</p><small>{m.source} · Importance {m.importance}/10 · {new Date(m.at).toLocaleDateString()}</small></div>)}</TabsContent><TabsContent value="bonds"><p className="fineprint">Bonds are directional. One character’s trust does not imply the other’s.</p><fieldset disabled={readOnly} className="readonly-fieldset"><form className="detail-block" onSubmit={async e=>{e.preventDefault();try{await act({type:'relationship',botId:b.id,targetId:target,affinity,note});setNote('');}catch{}}}><div className="field"><label>Other character</label><Choice label="Other character" value={target} onChange={setTarget} items={state.bots.filter((x:Bot)=>x.id!==b.id).map((x:Bot)=>({value:x.id,label:x.name}))}/></div><div className="range-row"><div className="range-label">Affinity <span>{affinity>0?'+':''}{affinity}</span></div><Slider aria-label="Affinity" min={-100} max={100} value={[affinity]} onValueChange={v=>setAffinity(v[0])}/></div><label className="field">Why this bond exists<textarea required maxLength={300} value={note} onChange={e=>setNote(e.target.value)}/></label><Button className="mt-4" disabled={busy||!target}>Save relationship</Button></form></fieldset>{b.relationships.map((r:any)=><div className="memory" key={r.targetId}><h3>{state.bots.find((x:Bot)=>x.id===r.targetId)?.name} <span className="tag">{r.affinity>0?'+':''}{r.affinity}</span></h3><p className="list-muted">{r.note}</p></div>)}</TabsContent></Tabs></>}
function WorldMonitor({state,monitor,capabilities,openBot}:any){
const living:Bot[]=state.bots.filter((b:Bot)=>b.status!=='dead'&&b.status!=='retired');
const active=living.filter((b:Bot)=>b.status==='online');
const managed=living.filter((b:Bot)=>b.autonomy?.managed===true);
const foreground=living.filter((b:Bot)=>b.simulationBand==='foreground');
const background=living.filter((b:Bot)=>b.simulationBand==='background');
const dormant=living.filter((b:Bot)=>b.simulationBand==='dormant');
const dead=state.bots.filter((b:Bot)=>b.status==='dead');
const zaku=state.bots.find((b:Bot)=>b.id==='hero-zaku'||(b.tier==='hero'&&b.name.toLowerCase()==='zaku'));
const zoneMap=new Map<string,{total:number;foreground:number;background:number;dormant:number;names:string[]}>();
for(const b of living){
const z=zoneMap.get(b.zone)??{total:0,foreground:0,background:0,dormant:0,names:[]};
z.total++;z[b.simulationBand]++;z.names.push(b.name);zoneMap.set(b.zone,z);
}
const zones=[...zoneMap.entries()].sort((a,b)=>b[1].total-a[1].total);
const worldEvents=[...(state.events??[])].reverse().slice(0,14);
const aleEvents=monitor?.events??[];
const lastAle=monitor?.ale?.lastEventAt?new Date(monitor.ale.lastEventAt):null;
const aleFresh=!!monitor?.ale?.activeRecently;
const systemRows=[
['AzerothCore',aleFresh?'Live':(capabilities.aleConfigured?'Waiting for telemetry':'Not configured'),aleFresh],
['ALE',capabilities.aleConfigured?(aleFresh?'Live':'Configured'):'Not configured',capabilities.aleConfigured&&aleFresh],
['Playerbots',managed.length>0?`${managed.length} persistent lives bound`:'Awaiting live bot telemetry',managed.length>0],
['AI brain',state.settings.provider==='deterministic'?'Deterministic':(capabilities.llmConfigured?state.settings.brains.heroModel:'Endpoint missing'),state.settings.provider==='deterministic'||capabilities.llmConfigured],
['GM SOAP',capabilities.soapConfigured?'Ready':'Not configured',!!capabilities.soapConfigured],
['Population Director',state.population.lastPlan?'Active':'Waiting for trigger',!!state.population.lastPlan]
];
return <div className="section-grid" style={{gap:20}}>
<div className="stats span-all">{[
[Users,'Living residents',living.length,`${managed.length} live-bound · ${active.length} online`],
[Sun,'Foreground',foreground.length,'Fully simulated nearby'],
[Moon,'Background + dormant',background.length+dormant.length,`${background.length} background · ${dormant.length} dormant`],
[Skull,'Memorialized deaths',dead.length,state.settings.hardcore.enabled?'Hardcore enabled':'Hardcore disabled']
].map(([Icon,label,value,note]:any)=><div className="panel stat" key={label}><div className="stat-label">{label}<Icon/></div><div className="stat-value">{value}</div><small>{note}</small></div>)}</div>
<Panel title="Zaku" icon={Brain}>{zaku?<button className="panel-body" style={{display:'block',width:'100%',textAlign:'left'}} onClick={()=>openBot(zaku.id)}>
<div className="detail-header" style={{marginBottom:18}}><Avatar bot={zaku}/><div><h2>{zaku.name}</h2><p className="subtitle">Lv. {zaku.level} {zaku.race} {zaku.role}</p></div><span className={`tag ${zaku.status}`} style={{marginLeft:'auto'}}>{zaku.status}</span></div>
<div className="health-row"><span>Location</span><strong>{zaku.zone}</strong></div>
<div className="health-row"><span>Simulation</span><strong>{zaku.simulationBand}</strong></div>
<div className="health-row"><span>Home</span><strong>{state.homes.find((h:any)=>h.id===zaku.homeId)?.name??'Unassigned'}</strong></div>
<div className="health-row"><span>Current goal</span><strong style={{maxWidth:'58%',textAlign:'right'}}>{zaku.goal}</strong></div>
<div className="health-row"><span>Live body</span><strong>{zaku.autonomy?.managed?'Observed Playerbot':'Awaiting binding'}</strong></div>
<div className="health-row"><span>Decisions</span><strong>{zaku.decisions}</strong></div>
{zaku.autonomy?.nextIntentAt&&<div className="health-row"><span>Next life decision</span><strong>{new Date(zaku.autonomy.nextIntentAt).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</strong></div>}
{zaku.lastDecision&&<div className="memory" style={{marginTop:16}}><small>LATEST DECISION</small><p>{zaku.lastDecision.action}: {zaku.lastDecision.reason}</p></div>}
<p className="fineprint">Tap Zaku to open his full identity, memories, bonds, and controls.</p>
</button>:<div className="empty-note">Zaku is not present in this realm state.</div>}</Panel>
<Panel title="World systems" icon={Server}><div className="panel-body">{systemRows.map(([name,status,ok]:any)=><div className="health-row" key={name}><span>{name}</span><strong><span style={{display:'inline-block',width:8,height:8,borderRadius:99,background:ok?'#7bd88f':'#8a96a8',marginRight:8}}/>{status}</strong></div>)}<div className="health-row"><span>ALE last world event</span><strong>{lastAle?lastAle.toLocaleTimeString():'None yet'}</strong></div><div className="health-row"><span>ALE command queue</span><strong>{monitor?`${monitor.commands?.pending??0} pending · ${monitor.commands?.failed??0} failed`:'Loading…'}</strong></div><p className="fineprint">“Live” means recent authoritative ALE activity reached AzerothAI. Configured alone does not mean the worldserver is currently online.</p></div></Panel>
<Panel title="Active zones" icon={MapPin}><div className="panel-body">{zones.length?zones.map(([zone,z])=><div key={zone} style={{padding:'12px 0',borderBottom:'1px solid rgba(255,255,255,.07)'}}><div style={{display:'flex',justifyContent:'space-between',gap:16}}><strong>{zone}</strong><span>{z.total}</span></div><div className="zone-row" style={{marginTop:8}}><span className="zone-label">{z.foreground} fg · {z.background} bg · {z.dormant} sleep</span><span className="bar"><i style={{width:`${Math.min(100,(z.total/Math.max(1,living.length))*100)}%`}}/></span></div><small>{z.names.slice(0,6).join(', ')}{z.names.length>6?` +${z.names.length-6} more`:''}</small></div>):<div className="empty-note">No living residents have a recorded location yet.</div>}</div></Panel>
<Panel title="Realm population" icon={Activity}><div className="panel-body"><div className="health-row"><span>Server target</span><strong>{state.settings.population.serverMax}</strong></div><div className="health-row"><span>Zone target</span><strong>{state.settings.population.defaultZoneTarget}</strong></div><div className="health-row"><span>Foreground ceiling</span><strong>{state.settings.population.foregroundMax}</strong></div><div className="health-row"><span>Background ceiling</span><strong>{state.settings.population.backgroundMax}</strong></div>{state.population.lastPlan?<><div className="memory" style={{marginTop:16}}><small>LATEST DIRECTOR PLAN</small><p>{state.population.lastPlan.trigger.kind} in {state.population.lastPlan.trigger.zone}</p><p className="list-muted">{state.population.lastPlan.selectedBotIds.length} resident(s) selected · {state.population.lastPlan.requested} requested.</p></div></>:<div className="empty-note" style={{marginTop:16}}>No population plan has fired yet.</div>}</div></Panel>
<Panel title="What is happening right now" icon={Radio} className="span-all"><div>{worldEvents.length?worldEvents.map((e:RealmEvent)=><div className="event-row" key={e.id}><span className="event-time">{new Date(e.at).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span><span className={`event-kind ${e.kind}`}>{e.kind}</span><span>{e.message}</span></div>):<div className="empty-note">The realm event feed is quiet.</div>}</div></Panel>
<Panel title="Authoritative ALE pulse" icon={Clock3} className="span-all"><div>{aleEvents.length?aleEvents.slice(0,12).map((e:any)=><div className="event-row" key={e.id}><span className="event-time">{new Date(e.at).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'})}</span><span className="event-kind system">ALE</span><span>{e.kind}{e.name?` · ${e.name}`:''}{e.zone?` · ${e.zone}`:''}{e.isBot?' · Playerbot':''}</span></div>):<div className="empty-note">No authoritative ALE world events have arrived yet. When the playable realm is online, login, combat, zone, quest, weather, and other server events will appear here.</div>}</div></Panel>
</div>
}
function WorldHousing({state,act,busy}:any){
const [kind,setKind]=useState('zone-change'),[zone,setZone]=useState(state.director.zone),[playerName,setPlayerName]=useState(state.director.playerName??''),[detail,setDetail]=useState('');
const plan=state.population.lastPlan;
const foreground=state.bots.filter((b:Bot)=>b.simulationBand==='foreground'&&b.status!=='dead'&&b.status!=='retired').length;
const background=state.bots.filter((b:Bot)=>b.simulationBand==='background'&&b.status!=='dead'&&b.status!=='retired').length;
const dormant=state.bots.filter((b:Bot)=>b.simulationBand==='dormant'&&b.status!=='dead'&&b.status!=='retired').length;
return <><div className="stats">{[[House,'Homes',state.homes.length,'Persistent places residents return to'],[Users,'Foreground',foreground,`Max ${state.settings.population.foregroundMax}`],[Activity,'Background',background,`Max ${state.settings.population.backgroundMax}`],[Pause,'Dormant',dormant,'Sleeping or cheaply simulated']].map(([Icon,label,value,note]:any)=><div className="panel stat" key={label}><div className="stat-label">{label}<Icon/></div><div className="stat-value">{value}</div><small>{note}</small></div>)}</div><div className="section-grid"><Panel title="World Population Director" icon={Compass}><form className="panel-body" onSubmit={async e=>{e.preventDefault();try{await act({type:'worldTrigger',kind,zone,playerName,detail});toast.success('World trigger evaluated');}catch{}}}><div className="form-grid"><label className="field">Trigger<Choice label="World trigger" value={kind} onChange={setKind} items={options(['zone-change','interaction','time','manual'])}/></label><label className="field">Zone<Input required value={zone} onChange={e=>setZone(e.target.value)}/></label><label className="field">Player name<Input value={playerName} onChange={e=>setPlayerName(e.target.value)}/></label><label className="field full">Context<textarea maxLength={500} value={detail} onChange={e=>setDetail(e.target.value)} placeholder="What happened in the world?"/></label></div><div className="form-footer"><Button disabled={busy}><Sparkles size={16}/>Evaluate population</Button></div><p className="fineprint">AzerothAI prefers existing residents with a believable reason to be here. It does not create disposable identities to fill a zone.</p></form></Panel><Panel title="Latest population plan" icon={Activity}><div className="panel-body">{plan?<><div className="health-row"><span>Trigger</span><strong>{plan.trigger.kind} · {plan.trigger.zone}</strong></div><div className="health-row"><span>Zone target</span><strong>{plan.target}</strong></div><div className="health-row"><span>Already active</span><strong>{plan.activeBefore}</strong></div><div className="health-row"><span>Requested</span><strong>{plan.requested}</strong></div><div className="health-row"><span>Capacity available</span><strong>{plan.capacity}</strong></div><div className="health-row"><span>Residents selected</span><strong>{plan.selectedBotIds.length}</strong></div>{plan.selectedBotIds.length>0&&<p className="list-muted" style={{marginTop:16}}>{plan.selectedBotIds.map((id:string)=>state.bots.find((b:Bot)=>b.id===id)?.name).filter(Boolean).join(', ')}</p>}{plan.rejectedReasons.map((x:string)=><p className="fineprint" key={x}>{x}</p>)}</>:<div className="empty-note">No population trigger has been evaluated yet.</div>}</div></Panel></div><Panel title="Housing registry" icon={House} className="mt-5"><Table className="data-table"><TableHeader><TableRow><TableHead>Home</TableHead><TableHead>Zone</TableHead><TableHead>Type</TableHead><TableHead>Residents</TableHead><TableHead>Safety</TableHead></TableRow></TableHeader><TableBody>{state.homes.map((h:any)=><TableRow key={h.id}><TableCell><strong>{h.name}</strong><small style={{display:'block'}}>{h.description}</small></TableCell><TableCell>{h.zone}</TableCell><TableCell>{h.type}</TableCell><TableCell>{h.residentIds.map((id:string)=>state.bots.find((b:Bot)=>b.id===id)?.name).filter(Boolean).join(', ')||'Vacant'} <small>({h.residentIds.length}/{h.capacity})</small></TableCell><TableCell>{h.safety}/100</TableCell></TableRow>)}</TableBody></Table></Panel></>
}
function Director({state,act,busy}:any){const [d,setD]=useState(state.director);return <div className="section-grid"><Panel title="Set the scene" icon={Compass}><form className="panel-body" onSubmit={async e=>{e.preventDefault();try{await act({type:'director',...d});toast.success('Direction saved and bot goals updated');}catch{}}}><div className="form-grid"><label className="field full">What kind of session do you want?<Choice label="Experience" value={d.experience} onChange={experience=>setD({...d,experience})} items={options(['Adventuring companions','A lively town','Exploration and discovery','Rivalries and tension'])}/></label><label className="field">Your character name<Input value={d.playerName} maxLength={40} onChange={e=>setD({...d,playerName:e.target.value})} placeholder="Optional"/></label><label className="field">Focus zone<Input required value={d.zone} maxLength={100} onChange={e=>setD({...d,zone:e.target.value})}/></label><label className="field full">Your direction<textarea required maxLength={500} value={d.brief} onChange={e=>setD({...d,brief:e.target.value})} style={{minHeight:140}}/></label></div><div className="form-footer"><Button disabled={busy}><Sparkles size={16}/>Apply realm direction</Button></div><p className="fineprint">Updates the goals of all non-retired bots. Run individual decisions from a bot profile to act on this direction.</p></form></Panel><Panel title="You play. The world responds." icon={Shield}><div className="panel-body"><div className="engine-box"><Users size={24}/><div><h3>Characters, not background noise</h3><p>A healer who remembers you. A trader with an agenda. A familiar rival on the road.</p></div></div><div className="engine-box"><Compass size={24}/><div><h3>Direction, not a scripted outcome</h3><p>Your brief goes into bot goals. Personality, memory, and available actions influence their next choice.</p></div></div><div className="engine-box"><Shield size={24}/><div><h3>The game stays the game</h3><p>The game connector translates intent. AzerothCore resolves movement, combat, loot, and progression.</p></div></div><div className="notice">Live Playerbots continue playing under AzerothCore even with no human online. AzerothAI periodically adds personality-, memory-, and goal-driven high-level intentions for authoritatively observed residents.</div></div></Panel></div>}
function EventStream({state,readOnly=false}:any){const [events,setEvents]=useState<RealmEvent[]>(state.events),[paused,setPaused]=useState(false),[kind,setKind]=useState('all'),[connection,setConnection]=useState('Connecting');useEffect(()=>{if(paused){setConnection('Paused');return;}setEvents(state.events);if(readOnly){setConnection('Live · read only');return;}const source=new EventSource(`/api/events?after=${state.sequence}`);source.addEventListener('realm',(e:MessageEvent)=>{const event=JSON.parse(e.data);setEvents(old=>old.some(x=>x.id===event.id)?old:[...old,event].slice(-500));setConnection('Connected');});source.addEventListener('gap',()=>void fetch('/api/state').then(r=>r.json()).then((s:any)=>setEvents(s.events)));source.onopen=()=>setConnection('Connected');source.onerror=()=>setConnection('Reconnecting');return()=>source.close();},[paused,readOnly,state.sequence]);return <><div className="toolbar"><Choice label="Event category" value={kind} onChange={setKind} items={options(['all','system','lifecycle','decision','memory','relationship','recipe','gm','death','housing','population','error'])}/><Button variant="outline" onClick={()=>setPaused(!paused)}>{paused?<Play size={15}/>:<Pause size={15}/>} {paused?'Resume stream':'Pause stream'}</Button><small>{connection}</small></div><Panel title="Realm event stream" icon={Activity} action={<small>Latest 500 events · replay on reconnect</small>}><Events events={events.filter(e=>kind==='all'||e.kind===kind)}/></Panel></>}
function Settings({state,act,busy,capabilities}:any){
const [s,setS]=useState(state.settings),[health,setHealth]=useState('');
const brains=s.brains;
const setBrains=(patch:any)=>setS({...s,brains:{...brains,...patch}});
return <div className="section-grid">
<Panel title="Server connector" icon={Server}><div className="panel-body"><div className="field"><label>Game connector</label><Choice label="Game connector" value={s.connector} onChange={connector=>setS({...s,connector})} items={[{value:'simulation',label:'Simulation · Ready to use'},{value:'azerothcore',label:'AzerothCore · HTTP bridge'}]}/></div><div className="health-row"><span>Game bridge configured</span><strong>{capabilities.bridgeConfigured?'Yes':'Not yet'}</strong></div><div className="health-row"><span>ALE control plane</span><strong>{capabilities.aleConfigured?'Configured':'Not yet'}</strong></div><div className="health-row"><span>GM SOAP configured</span><strong>{capabilities.soapConfigured?'Yes':'Not yet'}</strong></div><div className="health-row"><span>Mechanics authority</span><strong>AzerothCore</strong></div><Button className="mt-5" variant="outline" disabled={busy} onClick={async()=>{try{const r=await act({type:'health'});setHealth(r.health.detail);}catch{setHealth('Connection test failed. Check server configuration.')}}}><RefreshCw size={15}/>Test saved connector</Button>{health&&<p className="status-message" role="status">{health}</p>}</div></Panel>
<Panel title="AI brain router" icon={Brain}><div className="panel-body"><div className="field"><label>Decision provider</label><Choice label="Decision provider" value={s.provider} onChange={provider=>setS({...s,provider})} items={[{value:'deterministic',label:'Personality simulator · No model needed'},{value:'ollama',label:'Ollama · Local inference'},{value:'openai-compatible',label:'OpenAI-compatible endpoint'}]}/></div><div className="choice-row" style={{justifyContent:'space-between',marginTop:20}}><label htmlFor="brain-router">Tier-aware brain router</label><Switch id="brain-router" checked={brains.enabled} onCheckedChange={(enabled:boolean)=>setBrains({enabled})}/></div><label className="field" style={{marginTop:20}}>Resident model<Input value={brains.residentModel} onChange={e=>setBrains({residentModel:e.target.value})}/></label><label className="field" style={{marginTop:20}}>Hero / Zaku model<Input value={brains.heroModel} onChange={e=>setBrains({heroModel:e.target.value})}/></label><div className="choice-row" style={{justifyContent:'space-between',marginTop:20}}><label htmlFor="background-llm">Use LLM for background residents</label><Switch id="background-llm" checked={brains.backgroundUseLLM} onCheckedChange={(backgroundUseLLM:boolean)=>setBrains({backgroundUseLLM})}/></div><div className="form-grid" style={{marginTop:20}}><label className="field">Resident memories<Input type="number" min={1} max={50} value={brains.residentMemoryLimit} onChange={e=>setBrains({residentMemoryLimit:Number(e.target.value)})}/></label><label className="field">Hero memories<Input type="number" min={1} max={100} value={brains.heroMemoryLimit} onChange={e=>setBrains({heroMemoryLimit:Number(e.target.value)})}/></label><label className="field">Resident token budget<Input type="number" min={64} max={2048} value={brains.residentMaxTokens} onChange={e=>setBrains({residentMaxTokens:Number(e.target.value)})}/></label><label className="field">Hero token budget<Input type="number" min={64} max={4096} value={brains.heroMaxTokens} onChange={e=>setBrains({heroMaxTokens:Number(e.target.value)})}/></label></div><div className="health-row"><span>Local model endpoint configured</span><strong>{capabilities.llmConfigured?'Yes':'Not yet'}</strong></div><p className="fineprint">Default: Qwen3.5 4B for residents and Zaku. Background residents stay on deterministic utility logic unless enabled here, saving CPU for foreground conversations and important decisions.</p><div className="form-footer"><Button disabled={busy} onClick={async()=>{try{await act({type:'settings',...s,model:brains.residentModel});toast.success('Brain router saved');}catch{}}}>Save brain configuration</Button></div></div></Panel>
<Panel title="A modular control plane" icon={Network} className="span-all"><div className="panel-body section-grid"><div><h3>AzerothAI</h3><p className="list-muted">Identity → memory retrieval → brain routing → high-level intent</p></div><div><h3>AzerothCore + ALE</h3><p className="list-muted">Movement → combat → inventory → world rules → authoritative events</p></div></div></Panel>
</div>
}
function GM({state,act,busy,capabilities,setConfirm}:any){const [command,setCommand]=useState('.server info'),[live,setLive]=useState(false),[history,setHistory]=useState<string[]>([]),[index,setIndex]=useState(-1);const gmEvents=state.events.filter((e:RealmEvent)=>e.kind==='gm'||e.kind==='error').slice(-30);const shortcuts=[['Server info','.server info'],['World announcement','.announce Travelers, gather at the gates of Stormwind!'],['Teleport character','.teleport name Yourname Stormwind'],['Character info','.pinfo Yourname'],['Message of the day','.server motd']];return <div className="dashboard-grid"><Panel title="Worldserver console" icon={Terminal} action={<span className="sim-badge">{live?'LIVE COMMANDS':'SIMULATED COMMANDS'}</span>}><div className="console-output" role="log" aria-label="GM command history"><div className="console-welcome">&lt;GM&gt; AZEROTHAI<br/><span>Realm operations terminal · Command channel ready</span></div><p className="console-muted">{live?'Commands will execute on your configured worldserver.':'Simulation mode. No connection to a real worldserver.'}</p>{gmEvents.length?gmEvents.map((e:RealmEvent)=><div className={`console-entry ${e.kind==='error'?'console-error':''}`} key={e.id}><small>{new Date(e.at).toLocaleTimeString()}</small><pre>{e.message}</pre></div>):<p className="console-muted">Type .server info to get started.</p>}</div><form className="console-input" onSubmit={async e=>{e.preventDefault();if(!command.trim())return;setHistory(h=>[command,...h].slice(0,30));setIndex(-1);if(live){setConfirm({title:'Execute on the live worldserver?',description:command,action:{type:'gm',command,live:true,confirmed:true}});return;}try{await act({type:'gm',command,live:false});setCommand('');}catch{}}}><span>&gt;</span><Input aria-label="GM command" value={command} placeholder=".announce Your adventure begins here" maxLength={300} onChange={e=>setCommand(e.target.value)} onKeyDown={e=>{if(e.key==='ArrowUp'){e.preventDefault();const n=Math.min(index+1,history.length-1);setIndex(n);if(n>=0)setCommand(history[n]);}if(e.key==='ArrowDown'){e.preventDefault();const n=Math.max(-1,index-1);setIndex(n);setCommand(n>=0?history[n]:'');}}}/><Button disabled={busy||!command.trim()} aria-label="Run GM command"><Send size={16}/></Button></form></Panel><div><Panel title="GM toolbox" icon={Shield}><div className="panel-body"><div className="choice-row" style={{justifyContent:'space-between',marginBottom:20}}><label htmlFor="gm-live">Use live worldserver</label><Switch id="gm-live" checked={live} onCheckedChange={setLive} disabled={!capabilities.soapConfigured}/></div>{shortcuts.map(([label,cmd])=><button className="gm-shortcut" key={label} onClick={()=>setCommand(cmd)}><span>{label}</span><code>{cmd.split(' ').slice(0,label==='Teleport character'?2:1).join(' ')}</code></button>)}<p className="fineprint">Live commands use AzerothCore SOAP and require confirmation. Supported commands are limited to this toolbox; bot models cannot issue them.</p></div></Panel><Panel title="The blue-robed GM" icon={ScrollText} className="mt-5"><div className="panel-body"><p className="list-muted">In-game commands such as <code>.gm on</code>, <code>.gm visible off</code>, and <code>.summon</code> need your logged-in GM character. This web console has no character position or selected target.</p><p className="fineprint">The first prototype provides remote announcements, named teleports, and server/player information. More GM powers can be added explicitly.</p></div></Panel></div></div>}