diff --git a/app.js b/app.js
index 21c5c222..8140273b 100644
--- a/app.js
+++ b/app.js
@@ -5,6 +5,7 @@ import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'
import { SMAAPass } from 'three/addons/postprocessing/SMAAPass.js';
import { SpatialMemory } from './nexus/components/spatial-memory.js';
import { SessionRooms } from './nexus/components/session-rooms.js';
+import { EvenniaRoomPanel } from './nexus/components/evennia-room-panel.js';
// ═══════════════════════════════════════════
// NEXUS v1.1 — Portal System Update
@@ -707,6 +708,7 @@ async function init() {
createAshStorm();
SpatialMemory.init(scene);
SessionRooms.init(scene, camera, null);
+ EvenniaRoomPanel.init();
updateLoad(90);
loadSession();
@@ -2074,6 +2076,7 @@ function connectHermes() {
addChatMessage('system', 'Hermes link established.');
updateWsHudStatus(true);
refreshWorkshopPanel();
+ EvenniaRoomPanel.setConnected(true);
};
// Initialize MemPalace
@@ -2102,6 +2105,7 @@ function connectHermes() {
hermesWs = null;
updateWsHudStatus(false);
refreshWorkshopPanel();
+ EvenniaRoomPanel.setConnected(false);
if (wsReconnectTimer) clearTimeout(wsReconnectTimer);
wsReconnectTimer = setTimeout(connectHermes, 5000);
};
@@ -2112,6 +2116,16 @@ function connectHermes() {
}
function handleHermesMessage(data) {
+ // ── Evennia room snapshot events (#728) ──
+ if (data.type === 'evennia.room_snapshot') {
+ EvenniaRoomPanel.onRoomSnapshot(data);
+ return;
+ }
+ if (data.type === 'evennia.actor_located') {
+ EvenniaRoomPanel.onActorLocated(data);
+ return;
+ }
+
if (data.type === 'chat') {
addChatMessage(data.agent || 'timmy', data.text);
} else if (data.type === 'tool_call') {
diff --git a/index.html b/index.html
index 54ada489..7c812145 100644
--- a/index.html
+++ b/index.html
@@ -125,6 +125,19 @@
+
+
+
+
+
⊘
+
DISCONNECTED
+
No link to Evennia world.
+
+
diff --git a/nexus/components/evennia-room-panel.js b/nexus/components/evennia-room-panel.js
new file mode 100644
index 00000000..6dbe6176
--- /dev/null
+++ b/nexus/components/evennia-room-panel.js
@@ -0,0 +1,229 @@
+// ═══════════════════════════════════════════════════════
+// EVENNIA ROOM SNAPSHOT OPERATOR PANEL (Issue #728)
+// ═══════════════════════════════════════════════════════
+//
+// Renders the current Evennia room state in the Nexus HUD.
+// Consumes evennia.room_snapshot and evennia.actor_located
+// events from the Hermes WebSocket bridge.
+//
+// States:
+// offline — no WS connection
+// awaiting — connected but no room data yet
+// in-room — room snapshot loaded, render full panel
+//
+// Usage from app.js:
+// EvenniaRoomPanel.init();
+// EvenniaRoomPanel.onRoomSnapshot(data);
+// EvenniaRoomPanel.onActorLocated(data);
+// EvenniaRoomPanel.setConnected(bool);
+// ═══════════════════════════════════════════════════════
+
+export const EvenniaRoomPanel = (() => {
+
+ // ─── STATE ────────────────────────────────────────────
+ let _connected = false;
+ let _roomData = null; // latest evennia.room_snapshot payload
+ let _actorRoomId = null; // from evennia.actor_located
+ let _lastUpdate = null; // timestamp of last snapshot
+ let _panelEl = null; // DOM root
+ let _init = false;
+
+ // ─── DOM REFS ─────────────────────────────────────────
+ function _el(id) { return document.getElementById(id); }
+
+ // ─── INIT ─────────────────────────────────────────────
+ function init() {
+ _panelEl = _el('evennia-room-panel');
+ if (!_panelEl) {
+ console.warn('[EvenniaRoomPanel] Panel element not found in DOM.');
+ return;
+ }
+ _init = true;
+ _render();
+ console.log('[EvenniaRoomPanel] Initialized.');
+ }
+
+ // ─── EVENT HANDLERS ───────────────────────────────────
+
+ function onRoomSnapshot(data) {
+ _roomData = data;
+ _lastUpdate = data.timestamp || new Date().toISOString();
+ _actorRoomId = data.room_id || data.room_key || null;
+ _render();
+ }
+
+ function onActorLocated(data) {
+ _actorRoomId = data.room_id || data.room_key || null;
+ // If we get a location but no snapshot yet, show awaiting
+ if (!_roomData || (_roomData.room_id !== _actorRoomId && _roomData.room_key !== _actorRoomId)) {
+ _render();
+ }
+ }
+
+ function setConnected(connected) {
+ _connected = connected;
+ if (!connected) {
+ // Clear room data on disconnect — stale data is lying
+ _roomData = null;
+ _actorRoomId = null;
+ _lastUpdate = null;
+ }
+ _render();
+ }
+
+ // ─── RENDER ───────────────────────────────────────────
+
+ function _render() {
+ if (!_panelEl) return;
+
+ if (!_connected) {
+ _renderOffline();
+ } else if (!_roomData) {
+ _renderAwaiting();
+ } else {
+ _renderRoom();
+ }
+ }
+
+ function _renderOffline() {
+ _panelEl.innerHTML = `
+
+
+
⊘
+
DISCONNECTED
+
No link to Evennia world.
+
+ `;
+ _panelEl.classList.add('erp-state-offline');
+ _panelEl.classList.remove('erp-state-awaiting', 'erp-state-inroom');
+ }
+
+ function _renderAwaiting() {
+ _panelEl.innerHTML = `
+
+
+
◎
+
AWAITING SNAPSHOT
+
Connected. Waiting for room data…
+
+ `;
+ _panelEl.classList.add('erp-state-awaiting');
+ _panelEl.classList.remove('erp-state-offline', 'erp-state-inroom');
+ }
+
+ function _renderRoom() {
+ const room = _roomData;
+ const title = _esc(room.title || room.room_name || room.room_key || 'Unknown Room');
+ const desc = _esc(room.desc || 'No description available.');
+ const exits = Array.isArray(room.exits) ? room.exits : [];
+ const objects = Array.isArray(room.objects) ? room.objects : [];
+ const occupants = Array.isArray(room.occupants) ? room.occupants : [];
+ const roomId = _esc(room.room_id || room.room_key || '—');
+ const timeStr = _formatTime(_lastUpdate);
+
+ // Build exits list
+ let exitsHtml = '';
+ if (exits.length > 0) {
+ exitsHtml = exits.map(e => {
+ const name = _esc(e.key || e.name || '?');
+ const dest = _esc(e.destination_name || e.destination_id || e.destination_key || '');
+ return `
+ →
+ ${name}
+ ${dest ? `${dest}` : ''}
+
`;
+ }).join('');
+ } else {
+ exitsHtml = 'No visible exits.
';
+ }
+
+ // Build objects list
+ let objectsHtml = '';
+ if (objects.length > 0) {
+ objectsHtml = objects.map(o => {
+ const name = _esc(o.key || o.id || '?');
+ const desc = _esc(o.short_desc || '');
+ return `
+ ▪
+ ${name}
+ ${desc ? `${desc}` : ''}
+
`;
+ }).join('');
+ } else {
+ objectsHtml = 'No visible objects.
';
+ }
+
+ // Build occupants list
+ let occupantsHtml = '';
+ if (occupants.length > 0) {
+ occupantsHtml = occupants.map(o => {
+ const name = _esc(typeof o === 'string' ? o : (o.name || o.key || '?'));
+ return `${name}`;
+ }).join('');
+ }
+
+ _panelEl.innerHTML = `
+
+
+
${roomId}
+
${desc}
+
+
+
+
+
OBJECTS
+
${objectsHtml}
+
+
+ ${occupantsHtml ? `
+
+
OCCUPANTS
+
${occupantsHtml}
+
` : ''}
+
+
+
+ `;
+ _panelEl.classList.add('erp-state-inroom');
+ _panelEl.classList.remove('erp-state-offline', 'erp-state-awaiting');
+ }
+
+ // ─── UTILS ────────────────────────────────────────────
+
+ function _esc(str) {
+ const d = document.createElement('div');
+ d.textContent = str;
+ return d.innerHTML;
+ }
+
+ function _formatTime(isoStr) {
+ if (!isoStr) return '—';
+ try {
+ const d = new Date(isoStr);
+ return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
+ } catch {
+ return '—';
+ }
+ }
+
+ // ─── PUBLIC API ───────────────────────────────────────
+ return { init, onRoomSnapshot, onActorLocated, setConnected };
+
+})();
diff --git a/style.css b/style.css
index 2c3f70eb..433d6f2d 100644
--- a/style.css
+++ b/style.css
@@ -1580,3 +1580,229 @@ canvas#nexus-canvas {
text-transform: uppercase;
}
+/* ═══════════════════════════════════════════════════════
+ EVENNIA ROOM SNAPSHOT OPERATOR PANEL (#728)
+ ═══════════════════════════════════════════════════════ */
+
+.evennia-room-panel {
+ width: 280px;
+ background: rgba(5, 5, 16, 0.85);
+ backdrop-filter: blur(12px);
+ border: 1px solid rgba(74, 240, 192, 0.18);
+ border-left: 3px solid var(--color-primary, #4af0c0);
+ border-radius: 6px;
+ font-family: var(--font-body, 'JetBrains Mono', monospace);
+ font-size: 11px;
+ color: var(--color-text, #e0f0ff);
+ pointer-events: auto;
+ overflow: hidden;
+ transition: border-color 0.3s ease;
+}
+
+.evennia-room-panel.erp-state-offline {
+ border-left-color: var(--color-danger, #ff4466);
+}
+
+.evennia-room-panel.erp-state-awaiting {
+ border-left-color: var(--color-warning, #ffaa22);
+}
+
+.evennia-room-panel.erp-state-inroom {
+ border-left-color: var(--color-primary, #4af0c0);
+}
+
+.erp-header {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ border-bottom: 1px solid rgba(74, 240, 192, 0.1);
+ background: rgba(74, 240, 192, 0.04);
+}
+
+.erp-icon {
+ font-size: 12px;
+ color: var(--color-primary, #4af0c0);
+}
+
+.erp-title {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 1.2px;
+ color: var(--color-primary, #4af0c0);
+ flex: 1;
+}
+
+.erp-status-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.erp-status-dot.erp-offline {
+ background: var(--color-danger, #ff4466);
+ box-shadow: 0 0 6px var(--color-danger, #ff4466);
+}
+
+.erp-status-dot.erp-online {
+ background: var(--color-primary, #4af0c0);
+ box-shadow: 0 0 6px var(--color-primary, #4af0c0);
+}
+
+.erp-body {
+ padding: 8px 10px;
+ max-height: 320px;
+ overflow-y: auto;
+}
+
+/* Empty / offline states */
+.erp-empty-state {
+ text-align: center;
+ padding: 18px 10px;
+}
+
+.erp-empty-icon {
+ font-size: 22px;
+ color: rgba(74, 240, 192, 0.25);
+ margin-bottom: 6px;
+}
+
+.erp-empty-icon.erp-pulse {
+ animation: erpPulse 2s ease-in-out infinite;
+}
+
+@keyframes erpPulse {
+ 0%, 100% { opacity: 0.35; }
+ 50% { opacity: 0.9; }
+}
+
+.erp-empty-label {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 1.5px;
+ color: var(--color-text-muted, #8a9ab8);
+ margin-bottom: 4px;
+}
+
+.erp-empty-hint {
+ font-size: 10px;
+ color: rgba(138, 154, 184, 0.55);
+}
+
+/* Room content */
+.erp-room-id {
+ font-size: 9px;
+ color: rgba(138, 154, 184, 0.4);
+ letter-spacing: 0.8px;
+ text-transform: uppercase;
+ margin-bottom: 6px;
+}
+
+.erp-desc {
+ font-size: 11px;
+ color: rgba(224, 240, 255, 0.8);
+ line-height: 1.45;
+ margin-bottom: 10px;
+ border-left: 2px solid rgba(74, 240, 192, 0.15);
+ padding-left: 8px;
+}
+
+.erp-section {
+ margin-bottom: 8px;
+}
+
+.erp-section-label {
+ font-size: 9px;
+ font-weight: 700;
+ letter-spacing: 1.5px;
+ color: var(--color-primary, #4af0c0);
+ margin-bottom: 4px;
+ opacity: 0.7;
+}
+
+.erp-none {
+ font-size: 10px;
+ color: rgba(138, 154, 184, 0.35);
+ font-style: italic;
+ padding: 2px 0;
+}
+
+/* Exits */
+.erp-exit-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 2px 0;
+ font-size: 11px;
+}
+
+.erp-exit-arrow {
+ color: var(--color-secondary, #7b5cff);
+ font-weight: 700;
+}
+
+.erp-exit-name {
+ color: var(--color-secondary, #7b5cff);
+ font-weight: 600;
+}
+
+.erp-exit-dest {
+ color: rgba(138, 154, 184, 0.45);
+ font-size: 10px;
+}
+
+/* Objects */
+.erp-object-row {
+ display: flex;
+ align-items: baseline;
+ gap: 5px;
+ padding: 2px 0;
+ font-size: 11px;
+}
+
+.erp-object-icon {
+ color: var(--color-gold, #ffd700);
+ font-size: 8px;
+}
+
+.erp-object-name {
+ color: rgba(224, 240, 255, 0.75);
+ font-weight: 500;
+}
+
+.erp-object-desc {
+ color: rgba(138, 154, 184, 0.45);
+ font-size: 10px;
+}
+
+/* Occupants */
+.erp-occupants {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.erp-occupant {
+ font-size: 10px;
+ padding: 2px 6px;
+ background: rgba(74, 240, 192, 0.08);
+ border: 1px solid rgba(74, 240, 192, 0.15);
+ border-radius: 3px;
+ color: var(--color-primary, #4af0c0);
+}
+
+/* Footer */
+.erp-footer {
+ margin-top: 6px;
+ padding-top: 4px;
+ border-top: 1px solid rgba(74, 240, 192, 0.06);
+ text-align: right;
+}
+
+.erp-time {
+ font-size: 9px;
+ color: rgba(138, 154, 184, 0.3);
+ letter-spacing: 0.5px;
+}
+