Complete Classic telemetry, authenticated player replies and reversible cutover
Core tests / core (push) Successful in 27s

This commit is contained in:
Codex
2026-09-25 18:19:50 -07:00
parent d7d4e899f1
commit 6f9cdcb5a4
19 changed files with 394 additions and 17 deletions
+6
View File
@@ -1,6 +1,7 @@
import { env } from 'cloudflare:workers';
import { z } from 'zod';
import { requireAleOperator,rawAleEnabled } from '@/core/ale-auth';
import {runGM} from '@/core/gm';
import { compileAleOperatorRequest } from '@/core/ale';
import { queueAleCommand } from '@/core/ale-store';
import type { RuntimeConfig } from '@/core/types';
@@ -20,6 +21,11 @@ export async function POST(request:Request){
requireAleOperator(request,config);
const text=await request.text();if(text.length>16000)return Response.json({error:'Request too large'},{status:413});
const body=schema.parse(JSON.parse(text));
if(config.GAME_BACKEND==='cmangos-classic'){
if(body.kind!=='command')throw new Error('Classic supports native GM commands here; Lua/ALE methods belong to the Wrath backend.');
const result=await runGM(body.command,true,true,config);
return Response.json({ok:true,result});
}
const source=compileAleOperatorRequest(body,rawAleEnabled(config));
const queued=await queueAleCommand(env.DB!,source);
return Response.json({ok:true,command:queued},{status:202});
+10
View File
@@ -26,6 +26,7 @@ const STATEFUL_EVENTS=new Set([
'player.close-call',
'player.killed-by-creature',
'player.permanent-death',
'player.death',
'player.repop',
'player.resurrect'
]);
@@ -36,6 +37,11 @@ export async function POST(request:Request){
requireAleBridge(request,config);
const text=await request.text();if(text.length>40000)return Response.json({error:'Event too large'},{status:413});
const body=schema.parse(JSON.parse(text));
const eventId=typeof body.payload.nativeEventId==='string'?body.payload.nativeEventId.slice(0,100):null;
if(eventId){
await env.DB!.prepare('CREATE TABLE IF NOT EXISTS native_event_receipts (id TEXT PRIMARY KEY,at INTEGER)').run();
if(await env.DB!.prepare('SELECT id FROM native_event_receipts WHERE id=?').bind(eventId).first())return Response.json({ok:true,duplicate:true});
}
await recordAleEvent(env.DB!,body.kind,body.payload);
if((body.kind==='player.chat'||body.kind==='player.whisper')&&body.payload.isBot!==true){
@@ -54,6 +60,10 @@ export async function POST(request:Request){
if(zone)await execute(store,{type:'worldTrigger',kind:'zone-change',zone,playerName:String(body.payload.name??'').slice(0,40),detail:'Authoritative ALE human-player zone event'},config);
}
if(eventId){
await env.DB!.prepare('INSERT OR IGNORE INTO native_event_receipts VALUES (?,?)').bind(eventId,Math.floor(Date.now()/1000)).run();
await env.DB!.prepare('DELETE FROM native_event_receipts WHERE at<?').bind(Math.floor(Date.now()/1000)-8*86400).run();
}
return Response.json({ok:true});
}catch(e){
const message=e instanceof Error?e.message:'ALE event failure';
+5 -5
View File
@@ -41,7 +41,7 @@ export default function Home({readOnly=false}:{readOnly?:boolean}){
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">
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?'GAME SERVER LIVE':state?.settings.connector==='azerothcore'?'Native game 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>:<>
@@ -60,7 +60,7 @@ export default function Home({readOnly=false}:{readOnly?:boolean}){
{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>
<p className="fineprint" style={{textAlign:'center',marginTop:28}}>AzerothAI shapes identity and intent. The game server 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>
@@ -89,7 +89,7 @@ function WorldMonitor({state,monitor,capabilities,openBot}:any){
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],
['Game server',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],
@@ -144,9 +144,9 @@ function Settings({state,act,busy,capabilities}:any){
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="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:'Live game server · Native 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>Native game server</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>}
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 the configured game server 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>}