153 lines
52 KiB
TypeScript
153 lines
52 KiB
TypeScript
'use client';
|
||
import {RealmDiagnostics} from './realm-diagnostics';
|
||
import {RealmInsights} from './realm-insights';
|
||
import {CommunityPanel,OperationsPanel} from './community-panel';
|
||
import { useState,useEffect,useCallback } from 'react';
|
||
import { CheckCircle2,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],['Needs attention',Shield],['Gameplay Results',CheckCircle2],['Recovery Results',RefreshCw],['Bot Performance',Activity],['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 [focusName,setFocusName]=useState('');
|
||
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,name=''){setPage(p);setQuery('');setFilter('all');setFocusName(name);}
|
||
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>:<>
|
||
{['Needs attention','Gameplay Results','Recovery Results','Bot Performance'].includes(page)&&<RealmDiagnostics key={page+focusName} page={page} initialQuery={focusName} onNavigate={nav}/>}
|
||
{(page==='Nightly Report'||page==='Bot Activity')&&<RealmInsights key={page+focusName} mode={page} initialQuery={focusName}/>}
|
||
{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><GM> AzerothAI</span><p>Welcome to your world.</p><code>.announce An adventure awaits...</code></div><Button variant="outline" onClick={()=>nav('GM Console')}>Open GM console <Terminal size={15}/></Button></div></Panel></div></>}
|
||
{page==='World Monitor'&&<WorldMonitor state={state} monitor={monitor} capabilities={capabilities} openBot={setBotId}/>}
|
||
{page==='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"><GM> 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>></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>}
|