{page==='GM Console'?'THE WORLD IS YOUR SANDBOX':'AZEROTHAI CONTROL PLANE'}
{page==='Dashboard'?'Your realm, alive.':page}
{({'Dashboard':'The people, personalities, and stories that make your world.','World Monitor':'Watch Azeroth breathe: population, activity, systems, and the lives unfolding right now.','Realm Director':'Shape the adventure around you. Let your characters bring it to life.','World & Housing':'Homes, routines, simulation bands, and believable population flow.','Bots':'Give every character a purpose, then let their story unfold.','Bot Recipes':'Same model. Different personalities. A world of possibilities.','Memory & Bonds':'A shared history makes the next encounter matter.','Event Stream':'Follow the decisions and changes happening across your realm.','GM Console':'Pull up a chair. Put on the blue robe. Run your world.','Server & Models':'Connect the game engine and the minds behind your characters.'} as any)[page]}
{page==='GM Console'?'THE WORLD IS YOUR SANDBOX':'AZEROTHAI CONTROL PLANE'}
{page==='Dashboard'?'Your realm, alive.':page}
{({'Dashboard':'The people, personalities, and stories that make your world.','World Monitor':'Watch Azeroth breathe: population, activity, systems, and the lives unfolding right now.','Realm Director':'Shape the adventure around you. Let your characters bring it to life.','World & Housing':'Homes, routines, simulation bands, and believable population flow.','Bots':'Give every character a purpose, then let their story unfold.','Bot Recipes':'Same model. Different personalities. A world of possibilities.','Memory & Bonds':'A shared history makes the next encounter matter.','Event Stream':'Follow the decisions and changes happening across your realm.','GM Console':'Pull up a chair. Put on the blue robe. Run your world.','Server & Models':'Connect the game engine and the minds behind your characters.'} as any)[page]}
{readOnly?:page==='Bot Recipes'?:<>>}
{error&&
{error}
}
{!state?
{[1,2,3,4].map(n=>)}
:<>
- {page==='Dashboard'&&<>
{[[Users,'Active bots',state.bots.filter(b=>b.status==='online'&&(state.settings.connector==='simulation'||b.autonomy?.managed)).length,'Online in the simulation'],[BookOpen,'Bot recipes',state.recipes.length,'Distinct ways to experience the world'],[Brain,'Memories',state.bots.reduce((n,b)=>n+b.memories.length,0),'Stories carried into future decisions'],[Network,'Relationships',state.bots.reduce((n,b)=>n+b.relationships.length,0),'Directed bonds between characters']].map(([Icon,label,value,note]:any)=>
{label}
{value}
{label==='Active bots'&&state.settings.connector!=='simulation'?'Acknowledged by the game bridge':note}
{state.settings.provider==='deterministic'?'No model required. Try a decision from any online bot.':'Local model selected. Each decision includes identity and memories.'}
nav('Event Stream')}>View stream }>{}
Set the scene. Announce a gathering. Move a character to the next chapter of their story.
<GM> AzerothAI
Welcome to your world.
.announce An adventure awaits...
>}
+ {page==='Dashboard'&&<>
{[[Users,'Active bots',state.bots.filter(b=>b.status==='online'&&(state.settings.connector==='simulation'||b.autonomy?.managed)).length,'Online in the simulation'],[BookOpen,'Bot recipes',state.recipes.length,'Distinct ways to experience the world'],[Brain,'Memories',state.bots.reduce((n,b)=>n+b.memories.length,0),'Stories carried into future decisions'],[Network,'Relationships',state.bots.reduce((n,b)=>n+b.relationships.length,0),'Directed bonds between characters']].map(([Icon,label,value,note]:any)=>
{label}
{value}
{label==='Active bots'&&state.settings.connector!=='simulation'?'Acknowledged by the game bridge':note}
{state.settings.provider==='deterministic'?'No model required. Try a decision from any online bot.':'Local model selected. Each decision includes identity and memories.'}
nav('Event Stream')}>View stream }>{}
Set the scene. Announce a gathering. Move a character to the next chapter of their story.
AzerothAI shapes identity and intent. AzerothCore owns the rules of the world.
-
- {if(!o)setBotId(null)}}>Bot profileIdentity, intent, and the story so far.{selected&&state&&}
+
+ {if(!o)setBotId(null)}}>Bot profileIdentity, intent, and the story so far.{selected&&state&&}{if(!o)setConfirm(null)}}>{confirm?.title}{confirm?.description}Cancel{if(confirm)perform(confirm.action);setConfirm(null)}}>Confirm;
}
function CreateBot({state,act,busy,close}:any){const [name,setName]=useState(''),[role,setRole]=useState('Priest'),[race,setRace]=useState('Human'),[recipeId,setRecipeId]=useState(state.recipes[0].id),[goal,setGoal]=useState(`Support ${state.director.experience.toLowerCase()} in ${state.director.zone}`);return }
function RecipeEditor({recipe,act,busy,close}:any){const [r,setR]=useState(recipe??{name:'',description:'',tags:['Curious'],sociability:50,aggression:20,curiosity:70,economy:30,activities:['explore','quest']});const [tags,setTags]=useState(r.tags.join(', '));return }
-function BotProfile({bot:b,state,act,busy,perform,confirm}: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 <>
>
}
function Director({state,act,busy}:any){const [d,setD]=useState(state.director);return
Characters, not background noise
A healer who remembers you. A trader with an agenda. A familiar rival on the road.
Direction, not a scripted outcome
Your brief goes into bot goals. Personality, memory, and available actions influence their next choice.
The game stays the game
The game connector translates intent. AzerothCore resolves movement, combat, loot, and progression.
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.
Latest 500 events · replay on reconnect}>kind==='all'||e.kind===kind)}/>>}
function Settings({state,act,busy,capabilities}:any){
const [s,setS]=useState(state.settings),[health,setHealth]=useState('');
const brains=s.brains;
diff --git a/app/visitor/page.tsx b/app/visitor/page.tsx
new file mode 100644
index 0000000..d4b9a27
--- /dev/null
+++ b/app/visitor/page.tsx
@@ -0,0 +1,3 @@
+'use client';
+import Home from '../page';
+export default function Visitor(){return ;}
diff --git a/docs/spectator-monitor.md b/docs/spectator-monitor.md
index 00a817e..0a98ed7 100644
--- a/docs/spectator-monitor.md
+++ b/docs/spectator-monitor.md
@@ -11,8 +11,8 @@ observed bot parties; guild goals and progress; confirmed social activity.
Only autonomous bot-to-bot reply transcripts are public. Human conversations,
whispers, memories, account/character IDs, tokens, configuration and PC telemetry
are excluded. All text is rendered with textContent, never HTML interpolation.
-The HTTP service allows only explicit static paths and `/api/realm`, `/health`,
-`/robots.txt`. Mutation methods return 405; admin paths return 404.
+The HTTP service allows explicit public assets and read-only projections described
+below. Mutation methods return 405; administrative command paths return 404.
## Install and publish
@@ -58,3 +58,24 @@ After changing spectator source or assets, restart its service:
```sh
systemctl --user restart azerothai-spectator
```
+
+## Shared dashboard (visitor edition)
+
+The public root now serves the application's `/visitor` route, which uses the
+same React dashboard and CSS as the administrator. Navigation, filters, profiles,
+recipe details and the world monitor remain usable. Editing forms and GM inputs
+are disabled; the action dispatcher also refuses writes in visitor mode.
+The public server independently rejects every mutation method, including server
+actions, and never proxies a viewer-selected backend route or request headers.
+
+`/api/state` is an explicit public projection shaped for the shared UI. Bot memory
+views contain confirmed shared social history, not private player conversations
+or operator-injected memories. The event stream is a polling view of that public
+history. `/api/health`, `/api/world-monitor` and `/api/operations` return selected
+read-only indicators. Native account identifiers, raw command queues, credentials,
+endpoint URLs and private maintenance logs are omitted. Built JavaScript/CSS/font
+assets are served from `dist/client` with traversal and source-map requests denied.
+
+Build with `pnpm build` before restarting `azerothai-runtime` and
+`azerothai-spectator`. The existing Funnel address and private admin address stay
+separate. No cache actions are added.
diff --git a/scripts/spectator-server.py b/scripts/spectator-server.py
index 7fbd4fc..8f7d8ac 100644
--- a/scripts/spectator-server.py
+++ b/scripts/spectator-server.py
@@ -64,6 +64,58 @@ def project(state):
'parties': list(parties.values()), 'guilds': guilds, 'feed': feed[-60:][::-1]}
+def public_state(state):
+ """Admin-shaped data for the shared read-only UI, with an explicit allowlist."""
+ def pick(obj, keys):
+ return {k: obj[k] for k in keys.split() if k in obj and isinstance(obj[k], (str, int, float, bool))}
+ def recipe(r):
+ return {**pick(r, 'id name description sociability aggression curiosity economy version'),
+ 'tags': [text(t, 80) for t in r.get('tags', [])],
+ 'activities': [a for a in r.get('activities', []) if a in ['quest','explore','trade','rest','socialize']]}
+ projected = project(state)
+ feed = [{**e, 'id': 'public-'+str(i), 'status': 'confirmed'} for i, e in enumerate(reversed(projected['feed']))]
+ bots = []
+ for b in state.get('bots', []):
+ if not b.get('autonomy', {}).get('managed'):
+ continue
+ bot = pick(b, 'id name role race level zone status tier mortal plotArmorHpFloor simulationBand homeId recipeId recipeVersion goal decisions')
+ bot['personality'] = recipe(b.get('personality', {}))
+ bot['routine'] = {**pick(b.get('routine', {}), 'wakeHour sleepHour'), 'preferredZones': b.get('routine', {}).get('preferredZones', [])}
+ bot['autonomy'] = {'managed': True, **pick(b.get('autonomy', {}), 'observedAt lastIntentAt nextIntentAt')}
+ world = b.get('world') or {}
+ bot['world'] = {**pick(world, 'at team guildId guildName guildRank groupId inCombat x y mapId stationarySince'),
+ 'groupNames': [n for n in world.get('groupNames', []) if any(x['name']==n for x in state['bots'] if x.get('autonomy',{}).get('managed'))], 'nearby': []}
+ bot['relationships'] = [pick(r, 'targetId affinity note') for r in b.get('relationships', [])]
+ # Show shared history, never personal whispers or operator-injected memories.
+ bot['memories'] = [{'id': e['id'], 'text': e['text'], 'at': e['at'], 'source': 'observation', 'importance': 5}
+ for e in feed if b['name'] in e['names']]
+ if b.get('death'):
+ bot['death'] = pick(b['death'], 'id at zone level cause killer source verified')
+ if b.get('lastDecision'):
+ bot['lastDecision'] = {'action': b['lastDecision']['action'], 'reason': 'Following the resident’s current activity.', 'goal': bot.get('goal','')}
+ bots.append(bot)
+ settings = state.get('settings') or {}
+ return {'revision': state.get('revision',0), 'sequence': state.get('sequence',0), 'aleRosterAt': state.get('aleRosterAt',0),
+ 'bots': bots, 'recipes': [recipe(r) for r in state.get('recipes',[])],
+ 'homes': [{**pick(h,'id name zone type capacity storageSlots safety description'), 'ownerIds':h.get('ownerIds',[]), 'residentIds':h.get('residentIds',[])} for h in state.get('homes',[])],
+ 'events': [{'id':e['id'],'seq':i,'at':e['at'],'kind':'relationship' if e['kind']=='relationship' else 'lifecycle','message':e['text'],'mode':'azerothcore'} for i,e in enumerate(feed)],
+ 'settings': {**pick(settings,'connector provider model'),
+ 'brains':pick(settings.get('brains',{}),'enabled residentModel heroModel backgroundUseLLM residentMemoryLimit heroMemoryLimit residentMaxTokens heroMaxTokens'),
+ 'hardcore':pick(settings.get('hardcore',{}),'enabled requireVerifiedDeath memorialize'),
+ 'population':pick(settings.get('population',{}),'defaultZoneTarget zoneMax serverMax foregroundMax backgroundMax')},
+ 'director':{**pick(state.get('director',{}),'experience zone brief'),'playerName':''}, 'population':{}, 'operations':{},
+ 'opsRetentionDays':state.get('opsRetentionDays',7),
+ 'community':{'feed':feed,'evidence':[],'guilds':[{**g,'id':i} for i,g in enumerate(projected['guilds'])],
+ 'partiesEnabled':state.get('community',{}).get('partiesEnabled',True),
+ 'guildsEnabled':state.get('community',{}).get('guildsEnabled',True)}}
+
+
+def local_get(path):
+ opener = urllib.request.build_opener(NoRedirect(), urllib.request.ProxyHandler({}))
+ with opener.open('http://127.0.0.1:8787'+path, timeout=10) as response:
+ return response.read(8 * 1024 * 1024)
+
+
class Snapshot:
def __init__(self):
self.lock = threading.Lock()
@@ -81,7 +133,8 @@ class Snapshot:
raw = response.read(8 * 1024 * 1024 + 1)
if len(raw) > 8 * 1024 * 1024:
raise ValueError('State too large')
- self.data = project(json.loads(raw))
+ state = json.loads(raw)
+ self.data = {**project(state), "state": public_state(state)}
self.failed = False
except Exception:
self.failed = True
@@ -119,14 +172,46 @@ class Handler(BaseHTTPRequestHandler):
self.send_header('X-Frame-Options', 'DENY')
self.send_header('Referrer-Policy', 'no-referrer')
self.send_header('X-Robots-Tag', 'noindex, nofollow')
- self.send_header('Content-Security-Policy', "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
+ self.send_header('Content-Security-Policy', "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
self.end_headers()
if self.command != 'HEAD':
self.wfile.write(body)
def do_GET(self):
path = self.path.split('?', 1)[0]
- if path in ASSETS:
+ if path in ('/', '/visitor', '/visitor/'):
+ try:
+ self.reply(200, local_get('/visitor'), 'text/html; charset=utf-8')
+ except Exception:
+ self.reply(503, b'{"error":"Realm temporarily unavailable"}')
+ elif path == '/api/state':
+ data = SNAPSHOT.get()
+ self.reply(200 if data else 503, json.dumps(data['state'] if data else {'error':'Realm unavailable'}).encode())
+ elif path == '/api/health':
+ data = SNAPSHOT.get()
+ self.reply(200, json.dumps({'ok':bool(data),'aleConfigured':True,'aleActiveRecently':bool(data and not data['stale']), 'llmConfigured':bool(data and data['state']['settings'].get('provider')!='deterministic')}).encode())
+ elif path == '/api/world-monitor':
+ data = SNAPSHOT.get()
+ at = data['rosterAt'] if data else 0
+ from datetime import datetime, timezone
+ self.reply(200, json.dumps({'ok':bool(data),'ale':{'lastEventAt':datetime.fromtimestamp(at,timezone.utc).isoformat() if at else None,'activeRecently':bool(data and not data['stale'])},'events':[],'commands':{}}).encode())
+ elif path == '/api/operations':
+ try:
+ health = json.loads(local_get('/api/operations'))
+ samples = [{k:v for k,v in row.items() if k in ('host','at','cpuPercent','memoryTotalMB','memoryAvailableMB','diskFreeGB','databaseMB','logsMB','modelAvailable','runtimeService')} for row in health.get('samples',[])]
+ self.reply(200, json.dumps({'samples':samples,'stuck':[{k:v for k,v in row.items() if k in ('name','zone','minutes')} for row in health.get('stuck',[])]}).encode())
+ except Exception:
+ self.reply(503,b'{"error":"Health data unavailable"}')
+ elif path.startswith(('/assets/', '/_vinext/', '/_next/')):
+ # Built public assets only. No source maps, dotfiles or traversal.
+ import mimetypes
+ root = ROOT.parent / 'dist/client'
+ asset = (root / path.lstrip('/')).resolve()
+ if '%' in path or '..' in path or not asset.is_relative_to(root.resolve()) or asset.suffix not in ('.js','.css','.woff','.woff2','.png','.svg','.ico') or not asset.is_file():
+ self.reply(404,b'{"error":"Not found"}')
+ else:
+ self.reply(200,asset.read_bytes(),mimetypes.guess_type(str(asset))[0] or 'application/octet-stream')
+ elif path in ASSETS:
filename, content_type = ASSETS[path]
self.reply(200, (ROOT / filename).read_bytes(), content_type)
elif path == '/api/realm':
diff --git a/scripts/test-spectator.py b/scripts/test-spectator.py
index 6878938..e3aff8f 100644
--- a/scripts/test-spectator.py
+++ b/scripts/test-spectator.py
@@ -3,6 +3,7 @@ import importlib.util
import json
import threading
import unittest
+from unittest.mock import patch
import urllib.error
import urllib.request
from pathlib import Path
@@ -22,7 +23,11 @@ class SpectatorTests(unittest.TestCase):
('social-1', ['A', 'B'], 'SYNTHETIC_PROMPT'),
('social-1:reply', ['A', 'B'], 'Hello friend'),
('social-2:reply', ['HumanPlayer', 'A'], 'NOT_BOT_CHAT')]]
- projected = m.project({'bots': bots, 'settings': {'token': 'SECRET'}, 'community': {'feed': feed}})
+ source={'bots': bots, 'settings': {'token': 'SECRET'}, 'community': {'feed': feed}}
+ projected = m.project(source)
+ public = m.public_state(source)
+ for forbidden in ['PRIVATE_MEMORY','PRIVATE_WHISPER','SYNTHETIC_PROMPT','NOT_BOT_CHAT','SECRET','accountId']:
+ self.assertNotIn(forbidden,json.dumps(public))
serialized = json.dumps(projected)
for private in ['PRIVATE_MEMORY', 'PRIVATE_WHISPER', 'SYNTHETIC_PROMPT', 'NOT_BOT_CHAT', 'HumanPlayer', 'SECRET', 'accountId', 'internal-guid']:
self.assertNotIn(private, serialized)
@@ -35,18 +40,27 @@ class SpectatorTests(unittest.TestCase):
worker.start()
base = 'http://127.0.0.1:' + str(server.server_port)
try:
- for route in ['/', '/app.js', '/style.css', '/health']:
+ for route in ['/app.js', '/style.css', '/health']:
with urllib.request.urlopen(base + route) as response:
self.assertEqual(response.status, 200)
self.assertIn("default-src 'none'", response.headers['Content-Security-Policy'])
- for path in ['/api/state', '/api/actions', '/api/ale/operator', '/api/operations', '/.dev.vars', '/../.dev.vars', '/%2e%2e/.dev.vars', '//api/state']:
+ for path in ['/api/actions', '/api/ale/operator', '/.dev.vars', '/../.dev.vars', '/%2e%2e/.dev.vars', '/api/bridge/ale/poll', '/assets/../../.dev.vars', '/assets/private.map']:
with self.assertRaises(urllib.error.HTTPError) as error:
urllib.request.urlopen(base + path)
self.assertEqual(error.exception.code, 404)
+ error.exception.close()
+ with patch.object(m,'local_get',return_value=b'Visitor') as upstream:
+ with urllib.request.urlopen(base+'/') as response:
+ self.assertEqual(response.read(),b'Visitor')
+ upstream.assert_called_once_with('/visitor')
+ with patch.object(m.SNAPSHOT,'get',return_value={'state':{'bots':[]}}):
+ with urllib.request.urlopen(base+'/api/state') as response:
+ self.assertEqual(json.load(response),{'bots':[]})
for method in ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'TRACE']:
with self.assertRaises(urllib.error.HTTPError) as error:
urllib.request.urlopen(urllib.request.Request(base + '/api/actions', method=method, data=b'{}'))
self.assertEqual(error.exception.code, 405)
+ error.exception.close()
finally:
server.shutdown()
server.server_close()